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

voonhous 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 558007d76eaf test(spark): add stored procedure coverage (#19161)
558007d76eaf is described below

commit 558007d76eaf345f759a349bd4bc6d17967708d9
Author: Y Ethan Guo <[email protected]>
AuthorDate: Fri Aug 14 06:01:23 2026 -0700

    test(spark): add stored procedure coverage (#19161)
    
    * test(spark): add stored procedure coverage
    
    * Fix incorrect assertions in stored procedure coverage tests
    
    - FilterUtils: type-match numeric literals (long/double/ceil/floor), split
      out time-zone-aware timestamp functions that the util cannot evaluate.
    - CopyToTempView incremental: begin instant is an exclusive lower bound, so
      the '000'..last range yields 1 record, not 2.
    - LogFile merge/filter: lower hoodie.compact.inline.max.delta.commits so the
      explicit run_compaction actually produces the commit instant the merged
      scan requires.
    - ExportInstants: fix a real bug where desc=true threw
      UnsupportedOperationException because Collections.reverse ran on the
      read-only list from asJava over an immutable Scala collection.
    
    * Move export_instants fix and tests to a dedicated fix PR
    
    The export_instants desc-ordering product fix and its regression test move
    to their own fix(spark) PR; this PR stays pure test coverage of the other
    stored procedures.
    
    * Restore ExportInstantsProcedure to master, keeping the export_instants 
desc fix from #19172
    
    * review(19161): restore export_instants tests from #19172 and pin known 
limitations
    
    - Restore TestExportInstantsProcedure to master so the desc-ordering,
      action-filter and invalid-folder coverage added by #19172 stays intact
      (the PR would otherwise silently revert it on merge).
    - Correct the inverted incremental copy_to_temp_view comment (both bounds
      are inclusive on completion time; the end bound excludes the last
      commit) and assert the surviving record id instead of a bare count.
    - Pin the Long-column-narrowed-to-Int coercion (#19632) and the broken
      array predicates (#19633) in TestHoodieProcedureFilterUtils with
      assertions that flip when the bugs are fixed.
    - Document why the show_logfile_records merge test needs an explicit
      compaction and distinct update keys (#19634).
    - sync_validate action hardcode and connection NPE are tracked in #19635
      (no test change needed here).
    
    * review(19161): nit pass -- dedupe tests, tighten assertions, extract 
helpers
    
    - copy_to_temp_view: collapse the three query-type tests into one MOR
      table test; assert the read-optimized price (10 vs snapshot 99) so the
      RO arm is discriminated; use orderingFields instead of the deprecated
      preCombineField.
    - sync_validate: assert the exact output messages (dst-first branch,
      catch-up count of 2, no catch-up suffix on the shared-timeline case)
      instead of vacuous contains checks; drop the mandatory-arguments test
      (checkArgs is asserted ~19x across the suite already). The Catach typo
      mirrors the procedure output.
    - repair_clustering_plan: extract the shared table+plan setup helper;
      anchor the healthy validate_delete result by asserting planned files
      exist; add a NOT_FOUND_IN_PLAN case pinning that a file outside the
      plan is never deleted even with need_delete; make the allow_empty_plan
      case destructive (dry_run false) and assert the rewritten instant
      carries an empty plan.
    - show_clean_plans: assert the column-reference message so the unknown-
      column branch is discriminated from a parse failure; drop the unused
      insert.
    - show_logfile_*: extract the shared MOR create-table helper.
    - filter utils: rename the terse schema parameter/locals (s -> schema).
    
    * review(19161): fix red sync_validate asserts, tighten coverage, pin seven 
product bugs
    
    Round 1 (correctness fixes):
    - sync_validate: strip the default. qualifier the procedure never prints
      (suite was 0/2 red), stabilize the catch-up count via
      hoodie.parquet.small.file.limit=0 (was 3 from small-file packing), add a
      source-ahead test pinning the else-branch argument order, and pin the MOR
      deltacommit failure (#19635)
    - show_logfile_records: exact merged/unmerged counts, assert the commit
      instant the merged scan needs, and pin the HoodieDeleteBlock MatchError on
      a v9 log holding data+delete blocks, for both procedures (#19634)
    - copy_to_temp_view: correct the incremental range comment (OPEN_CLOSED),
      read the global view through global_temp from a new session (the assert
      was reading the local view), cover createGlobalTempView including the
      duplicate-name error
    - filter utils: pin reversed-operand coercion and the Float/Short/Byte/
      Decimal literal matrix (#19632), pin unknown functions silently dropping
      rows while validateFilterExpression accepts them (#19638)
    - repair_clustering_plan: cover backup => false (no .repair created)
    - new TestProcedureParameterImpl: equals/hashCode regression for #19167
      (aborts with StackOverflowError against the pre-fix code)
    
    Round 2 (review follow-ups):
    - sync_validate: the record-count modes fail fast offline, so pin both
      cause-chain shapes -- complete masks the SQLException behind an NPE
      (#19635 bug 2), latestPartitions surfaces it
    - show_logfile procedures: assert the single-log-file glob precondition the
      MatchError pin depends on; pin show_logfile_metadata record_count and
      block_type (previously asserted nowhere)
    - show_clean_plans: archival-forcing test pins all-null plan fields for
      archived cleans and IOException from show_cleans/show_cleans_metadata
      (#19639 covers all three); field lookups by name; the vacuous
      showArchived test (never archived anything) is absorbed and deleted
    
    * review(19161): pin last uncovered arms, tighten comment precision
    
    - show_logfile_records: pin #19634 crash 4 -- a delete-only v9 log file
      NPEs (asserted over the cause chain; Spark wraps the NPE as
      INTERNAL_ERROR); name both schema-resolution sites in the comment
      (merged reads the last globbed file, unmerged re-reads per file)
    - sync_validate: pin the never-written-destination sentinel (empty commits
      timeline falls back to "0" and the whole source timeline becomes
      catch-up); correct the transport-setup wording (port -1 is rejected
      before any connect, not during URI parsing)
    - show cleans suite: pre-archival showArchived controls (all three
      procedures succeed over an empty archive, so the post-archival failures
      are attributable to archival); #19639 comment updated to the retitled
      all-three-procedures scope
    
    ---------
    
    Co-authored-by: voon <[email protected]>
---
 .../procedure/TestCopyToTempViewProcedure.scala    |  86 +++++-
 .../procedure/TestHoodieLogFileProcedure.scala     | 224 ++++++++++++---
 .../procedure/TestHoodieProcedureFilterUtils.scala | 310 +++++++++++++++++++++
 .../procedure/TestProcedureParameterImpl.scala     |  63 +++++
 .../TestRepairClusteringPlanProcedure.scala        | 164 ++++++++---
 .../hudi/procedure/TestShowCleansProcedures.scala  | 236 +++++++++++-----
 .../TestValidateHoodieSyncProcedure.scala          | 254 +++++++++++++++++
 7 files changed, 1190 insertions(+), 147 deletions(-)

diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestCopyToTempViewProcedure.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestCopyToTempViewProcedure.scala
index 0466681d611d..becb0cee46de 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestCopyToTempViewProcedure.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestCopyToTempViewProcedure.scala
@@ -170,11 +170,93 @@ class TestCopyToTempViewProcedure extends 
HoodieSparkSqlTestBase {
       val row2 = spark.sql(s"""call 
copy_to_temp_view(table=>'$tableName',view_name=>'$viewName',global=>true,replace=>true)""").collectAsList()
       assert(row2.size() == 1 && row2.get(0).get(0) == 0)
 
+      // 4: the global view is visible from another session through the 
global_temp database
       newSession = spark.newSession()
-      // 4: query view in other session
-      val newViewCount = spark.sql(s"""select count(1) from 
$viewName""").collectAsList()
+      val newViewCount = newSession.sql(s"""select count(1) from 
global_temp.$viewName""").collectAsList()
       assert(newViewCount.size() == 1 && newViewCount.get(0).get(0) == 4)
 
+      // 5: copyToTempView with global=true and replace=false goes through 
createGlobalTempView
+      val globalViewName = generateTableName
+      val row3 = spark.sql(s"""call 
copy_to_temp_view(table=>'$tableName',view_name=>'$globalViewName',global=>true,replace=>false)""").collectAsList()
+      assert(row3.size() == 1 && row3.get(0).get(0) == 0)
+      val globalViewCount = spark.newSession().sql(s"""select count(1) from 
global_temp.$globalViewName""").collectAsList()
+      assert(globalViewCount.size() == 1 && globalViewCount.get(0).get(0) == 4)
+
+      // 6: re-creating the same global view without replace fails
+      val globalViewExistsErrorMsg = if (HoodieSparkUtils.gteqSpark3_4) {
+        s"[TEMP_TABLE_OR_VIEW_ALREADY_EXISTS] Cannot create the temporary view 
`$globalViewName` because it already exists."
+      } else {
+        s"Temporary view '$globalViewName' already exists"
+      }
+      checkExceptionContain(s"""call 
copy_to_temp_view(table=>'$tableName',view_name=>'$globalViewName',global=>true,replace=>false)""")(globalViewExistsErrorMsg)
+    }
+  }
+
+  test("Test Call copy_to_temp_view Procedure with incremental, read_optimized 
and as_of_instant") {
+    withTempDir { tmp =>
+      val tableName = generateTableName
+      // MOR so the read-optimized view diverges from the snapshot once an 
update lands in a log file.
+      spark.sql(
+        s"""
+           |create table $tableName (
+           |  id int,
+           |  name string,
+           |  price double,
+           |  ts long
+           |) using hudi
+           | location '${tmp.getCanonicalPath}/$tableName'
+           | tblproperties (
+           |  type = 'mor',
+           |  primaryKey = 'id',
+           |  orderingFields = 'ts'
+           | )
+       """.stripMargin)
+
+      spark.sql(s"insert into $tableName select 1, 'a1', 10, 1000")
+      spark.sql(s"insert into $tableName select 2, 'a2', 20, 2000")
+
+      val commits = spark.sql(s"select distinct _hoodie_commit_time from 
$tableName order by _hoodie_commit_time")
+        .collect().map(_.getString(0))
+      assert(commits.length == 2)
+
+      // Incremental query type requires begin/end instant times.
+      val incViewName = generateTableName
+      checkExceptionContain(
+        s"call copy_to_temp_view(table => '$tableName', view_name => 
'$incViewName', query_type => 'incremental')")(
+        "begin_instance_time and end_instance_time can not be null")
+
+      // Incremental reads filter on *completion* time, start exclusive and 
end inclusive
+      // (RangeType.OPEN_CLOSED). begin => '000' sits below every completion 
time, so it drops
+      // nothing; end_instance_time is the last commit's *requested* time, 
which is below that
+      // commit's own completion time, so the last commit is excluded and only 
the first commit's
+      // record (id = 1) survives.
+      val incResult = spark.sql(
+        s"""call copy_to_temp_view(table => '$tableName', view_name => 
'$incViewName',
+           | query_type => 'incremental', begin_instance_time => '000', 
end_instance_time => '${commits.last}')"""
+          .stripMargin).collectAsList()
+      assert(incResult.size() == 1 && incResult.get(0).get(0) == 0)
+      val incRows = spark.sql(s"select id from $incViewName").collectAsList()
+      assert(incRows.size() == 1 && incRows.get(0).getInt(0) == 1)
+
+      // The update goes to a log file, so the read-optimized view (base files 
only) still sees the
+      // pre-update price where a snapshot view would see 99.
+      spark.sql(s"update $tableName set price = 99 where id = 1")
+      val roViewName = generateTableName
+      val roResult = spark.sql(
+        s"""call copy_to_temp_view(table => '$tableName', view_name => 
'$roViewName',
+           | query_type => 'read_optimized')""".stripMargin).collectAsList()
+      assert(roResult.size() == 1 && roResult.get(0).get(0) == 0)
+      val roRows = spark.sql(s"select price from $roViewName where id = 
1").collectAsList()
+      assert(roRows.size() == 1 && roRows.get(0).getDouble(0) == 10.0)
+
+      // The snapshot at the first commit contains only the first record.
+      val asOfViewName = generateTableName
+      val asOfResult = spark.sql(
+        s"""call copy_to_temp_view(table => '$tableName', view_name => 
'$asOfViewName',
+           | as_of_instant => 
'${commits.head}')""".stripMargin).collectAsList()
+      assert(asOfResult.size() == 1 && asOfResult.get(0).get(0) == 0)
+      val asOfRows = spark.sql(s"select id from $asOfViewName").collectAsList()
+      assert(asOfRows.size() == 1 && asOfRows.get(0).getInt(0) == 1)
     }
   }
 }
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestHoodieLogFileProcedure.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestHoodieLogFileProcedure.scala
index 67b770636a3e..95342715896b 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestHoodieLogFileProcedure.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestHoodieLogFileProcedure.scala
@@ -19,29 +19,47 @@
 
 package org.apache.spark.sql.hudi.procedure
 
+import org.apache.hudi.common.fs.FSUtils
+import org.apache.hudi.storage.StoragePath
+import org.apache.hudi.testutils.HoodieClientTestUtils.createMetaClient
+
 class TestHoodieLogFileProcedure extends HoodieSparkProcedureTestBase {
+
+  /**
+   * The throwable and its causes, in order. Null-terminated, and capped so a 
self-referential
+   * cause cannot spin forever.
+   */
+  private def causeChain(t: Throwable): Seq[Throwable] =
+    Iterator.iterate(t)(_.getCause).takeWhile(_ != null).take(16).toSeq
+
+  private def chainTypes(t: Throwable): String = 
causeChain(t).map(_.getClass.getName).mkString(" <- ")
+
+  private def createMorTable(tableName: String, tablePath: String, 
tableVersion: Option[Int] = None): Unit = {
+    val versionProperty = tableVersion.map(version => s", 
hoodie.write.table.version = '$version'").getOrElse("")
+    spark.sql(
+      s"""
+         |create table $tableName (
+         |  id int,
+         |  name string,
+         |  price double,
+         |  ts long,
+         |  partition long
+         |) using hudi
+         | partitioned by (partition)
+         | location '$tablePath'
+         | tblproperties (
+         |  type = 'mor',
+         |  primaryKey = 'id',
+         |  orderingFields = 'ts'$versionProperty
+         | )
+       """.stripMargin)
+  }
+
   test("Test Call show_logfile_metadata Procedure") {
     withTempDir { tmp =>
       val tableName = generateTableName
       val tablePath = s"${tmp.getCanonicalPath}/$tableName"
-      // create table
-      spark.sql(
-        s"""
-           |create table $tableName (
-           |  id int,
-           |  name string,
-           |  price double,
-           |  ts long,
-           |  partition int
-           |) using hudi
-           | partitioned by (partition)
-           | location '$tablePath'
-           | tblproperties (
-           |  type = 'mor',
-           |  primaryKey = 'id',
-           |  orderingFields = 'ts'
-           | )
-       """.stripMargin)
+      createMorTable(tableName, tablePath)
       // insert data to table
       spark.sql(s"insert into $tableName select 1, 'a1', 10, 1000, 1000")
       spark.sql(s"insert into $tableName select 2, 'a2', 20, 1500, 1500")
@@ -57,6 +75,11 @@ class TestHoodieLogFileProcedure extends 
HoodieSparkProcedureTestBase {
       assertResult(1) {
         result.length
       }
+      // The single log block holds the one updated record, and the block type 
is version dependent:
+      // v10 native logs give PARQUET_DATA_BLOCK, v9 avro logs give 
AVRO_DATA_BLOCK.
+      assertResult(1)(result.head.getInt(1))
+      assert(result.head.getString(2).endsWith("DATA_BLOCK"),
+        s"unexpected block type ${result.head.getString(2)}")
     }
   }
 
@@ -64,24 +87,7 @@ class TestHoodieLogFileProcedure extends 
HoodieSparkProcedureTestBase {
     withTempDir { tmp =>
       val tableName = generateTableName
       val tablePath = s"${tmp.getCanonicalPath}/$tableName"
-      // create table
-      spark.sql(
-        s"""
-           |create table $tableName (
-           |  id int,
-           |  name string,
-           |  price double,
-           |  ts long,
-           |  partition long
-           |) using hudi
-           | partitioned by (partition)
-           | location '$tablePath'
-           | tblproperties (
-           |  type = 'mor',
-           |  primaryKey = 'id',
-           |  orderingFields = 'ts'
-           | )
-       """.stripMargin)
+      createMorTable(tableName, tablePath)
       // insert data to table
       spark.sql(s"insert into $tableName select 1, 'a1', 10, 1000, 1000")
       spark.sql(s"insert into $tableName select 2, 'a2', 20, 1500, 1500")
@@ -100,4 +106,150 @@ class TestHoodieLogFileProcedure extends 
HoodieSparkProcedureTestBase {
       }
     }
   }
+
+  test("Test Call show_logfile_records Procedure with merge and filter") {
+    // Keep automatic cleaning off so the pre-compaction log files survive for 
the merged scan. The
+    // lowered compaction trigger + explicit run_compaction below are a 
workaround: the merged scan
+    // resolves the latest instant via 
getCommitAndReplaceTimeline.lastInstant.get, which throws on
+    // a deltacommit-only (never compacted) MOR table; see #19634.
+    withSQLConf("hoodie.clean.automatic" -> "false", 
"hoodie.compact.inline.max.delta.commits" -> "1") {
+      withTempDir { tmp =>
+        val tableName = generateTableName
+        val tablePath = s"${tmp.getCanonicalPath}/$tableName"
+        createMorTable(tableName, tablePath)
+        spark.sql(s"insert into $tableName select 1, 'a1', 10, 1000, 1000")
+        spark.sql(s"insert into $tableName select 2, 'a2', 20, 1500, 1000")
+        // Each update touches a distinct key on purpose: a key updated in 
more than one log block
+        // makes the merged scan cast the merged record to HoodieRecordPayload 
and fail with a
+        // ClassCastException, so the merge-is-a-no-op shape is the only one 
that works; see #19634.
+        spark.sql(s"update $tableName set name = 'b1' where id = 1")
+        spark.sql(s"update $tableName set name = 'b2' where id = 2")
+
+        // Compaction produces a commit instant on the timeline, which the 
merged scan needs to
+        // determine the latest instant time; the delta log files remain on 
disk. The lowered
+        // delta-commit threshold above already compacts inline, so this call 
usually has nothing
+        // left to schedule and returns no rows -- it is the fallback for the 
day inline compaction
+        // stops firing. Assert the precondition the merged scan actually 
depends on, otherwise it
+        // dies later with an opaque NoSuchElementException; see #19634.
+        spark.sql(s"call run_compaction(op => 'run', table => 
'$tableName')").collect()
+        assert(createMetaClient(spark, 
tablePath).getActiveTimeline.getCommitAndReplaceTimeline.lastInstant.isPresent,
+          "compaction must produce a commit instant for the merged scan")
+
+        val pattern = s"$tablePath/*/*.log.*"
+
+        // Merged scan returns one row per updated key.
+        val merged = spark.sql(
+          s"""call show_logfile_records(table => '$tableName', 
log_file_path_pattern => '$pattern', merge => true, limit => 
10)""".stripMargin).collect()
+        assertResult(2)(merged.length)
+
+        // Unmerged scan, used as the baseline for the filter assertions. 
Because every key is
+        // updated exactly once, merging is a no-op here and both scans return 
the same rows, so
+        // this asserts no merge-vs-no-merge output distinction: a shape that 
would show one needs a
+        // key updated in more than one log block, which the merged scan 
cannot read; see #19634.
+        val unfiltered = spark.sql(
+          s"""call show_logfile_records(table => '$tableName', 
log_file_path_pattern => '$pattern', limit => 10)""".stripMargin).collect()
+        assertResult(merged.length)(unfiltered.length)
+
+        // A filter that always holds keeps every row.
+        val keepAll = spark.sql(
+          s"""call show_logfile_records(table => '$tableName', 
log_file_path_pattern => '$pattern', limit => 10, filter => "records IS NOT 
NULL")""".stripMargin).collect()
+        assertResult(unfiltered.length)(keepAll.length)
+
+        // A filter that never holds drops every row.
+        val dropAll = spark.sql(
+          s"""call show_logfile_records(table => '$tableName', 
log_file_path_pattern => '$pattern', limit => 10, filter => "records LIKE 
'%__no_such_token__%'")""".stripMargin).collect()
+        assertResult(0)(dropAll.length)
+      }
+    }
+  }
+
+  test("Test Call show_logfile_records Procedure over a non-data log block") {
+    withTempDir { tmp =>
+      val tableName = generateTableName
+      val tablePath = s"${tmp.getCanonicalPath}/$tableName"
+      // Table version 9 keeps deletes inline in the *.log.* files; under the 
v10 native log default
+      // they land in separate *.deletes.* files that the log glob below would 
not match.
+      createMorTable(tableName, tablePath, Some(9))
+      spark.sql(s"insert into $tableName select 1, 'a1', 10, 1000, 1000")
+      spark.sql(s"insert into $tableName select 2, 'a2', 20, 1500, 1000")
+      // One statement that both updates and deletes, so the delta commit 
appends a data block and a
+      // delete block to the same log file. Standalone deletes are not the 
only way to trip the NPE:
+      // both branches of show_logfile_records requireNonNull a schema read 
off a log file, they just
+      // pick the file differently. The merged branch resolves the reader 
schema once from the last
+      // globbed file; the unmerged branch re-reads it per file and dies on 
the first file without a
+      // data block (or not at all, if `limit` is filled by earlier files 
first). With the single-file
+      // glob asserted below, both land on the same file. The CLI twin fixed 
this in HUDI-6694 (#9445)
+      // with per-file null tolerance and a backward scan for the schema; see 
#19634.
+      spark.sql(
+        s"""
+           |merge into $tableName as target
+           |using (
+           |  select cast(1 as int) as id, 'x1' as name, cast(10 as double) as 
price,
+           |    cast(2000 as long) as ts, cast(1000 as long) as partition
+           |  union all
+           |  select cast(2 as int) as id, 'b2' as name, cast(20 as double) as 
price,
+           |    cast(2000 as long) as ts, cast(1000 as long) as partition
+           |) src
+           |on target.id = src.id
+           |when matched and src.id = 1 then delete
+           |when matched and src.id = 2 then update set target.name = 
src.name, target.ts = src.ts
+           |""".stripMargin)
+
+      // Known limitation: both show_logfile procedures match log blocks with 
a single
+      // `case dataBlock: HoodieDataBlock` arm and no default arm, so a 
non-data block -- here the
+      // delete block -- raises a MatchError instead of being skipped; see 
#19634. A fix flips these
+      // assertions to assert the delete block is skipped, or surfaced, 
gracefully.
+      val pattern = s"$tablePath/*/*.log.*"
+
+      // The MatchError pins below depend on small-file packing putting both 
keys in ONE file group,
+      // so the data block and the delete block share a single log file. If 
they ever split into two
+      // file groups, the last globbed file would be delete only and the 
procedures would die with a
+      // bare NPE on the null log-file schema instead, turning this test red 
for the wrong reason.
+      val globbedLogFiles = FSUtils.getGlobStatusExcludingMetaFolder(
+        createMetaClient(spark, tablePath).getStorage, new 
StoragePath(pattern))
+      assertResult(1, "the delete block must share one log file with the data 
block")(globbedLogFiles.size())
+
+      val deleteBlockMatchError = "of class 
org.apache.hudi.common.table.log.block.HoodieDeleteBlock"
+      checkExceptionContain(
+        s"""call show_logfile_records(table => '$tableName', 
log_file_path_pattern => '$pattern', limit => 10)""")(
+        deleteBlockMatchError)
+      checkExceptionContain(
+        s"""call show_logfile_metadata(table => '$tableName', 
log_file_path_pattern => '$pattern', limit => 10)""")(
+        deleteBlockMatchError)
+    }
+  }
+
+  test("Test Call show_logfile_records Procedure over a delete-only log file") 
{
+    withTempDir { tmp =>
+      val tableName = generateTableName
+      val tablePath = s"${tmp.getCanonicalPath}/$tableName"
+      // Table version 9 keeps deletes inline in the *.log.* files; under the 
v10 native log default
+      // they land in separate *.deletes.* files that the log glob below would 
not match.
+      createMorTable(tableName, tablePath, Some(9))
+      spark.sql(s"insert into $tableName select 1, 'a1', 10, 1000, 1000")
+      spark.sql(s"insert into $tableName select 2, 'a2', 20, 1500, 1000")
+      spark.sql(s"delete from $tableName where id = 1")
+
+      val pattern = s"$tablePath/*/*.log.*"
+      // The inserts go to a base file, so the only log file the glob matches 
holds just the delete
+      // block -- the shape the pin below depends on.
+      val globbedLogFiles = FSUtils.getGlobStatusExcludingMetaFolder(
+        createMetaClient(spark, tablePath).getStorage, new 
StoragePath(pattern))
+      assertResult(1, "the delete block must be alone in the only log 
file")(globbedLogFiles.size())
+
+      // Known limitation: the unmerged path requireNonNulls the schema it 
reads per log file, and a
+      // delete-only log file carries none, so the call dies with an NPE; see 
#19634 crash 4. A fix
+      // -- porting HUDI-6694's per-file null tolerance and backward schema 
scan -- flips this to
+      // returning the delete-block file gracefully.
+      // Assert the exception TYPE over the cause chain, message-agnostic: NPE 
is in the
+      // internal-error wrap set of every Spark version, so it arrives as an 
[INTERNAL_ERROR]
+      // SparkException rather than bare, and the NPE itself carries no 
message of its own
+      // (Objects.requireNonNull with no message argument), with no 
helpful-NPE text on JDK 11.
+      val failure = intercept[Throwable] {
+        spark.sql(
+          s"""call show_logfile_records(table => '$tableName', 
log_file_path_pattern => '$pattern', limit => 10)""".stripMargin).collect()
+      }
+      assert(causeChain(failure).exists(_.isInstanceOf[NullPointerException]), 
chainTypes(failure))
+    }
+  }
 }
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestHoodieProcedureFilterUtils.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestHoodieProcedureFilterUtils.scala
new file mode 100644
index 000000000000..595b49a67929
--- /dev/null
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestHoodieProcedureFilterUtils.scala
@@ -0,0 +1,310 @@
+/*
+ * 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.spark.sql.Row
+import org.apache.spark.sql.hudi.command.procedures.HoodieProcedureFilterUtils
+import org.apache.spark.sql.types._
+
+import java.sql.{Date, Timestamp}
+
+/**
+ * Direct unit tests for [[HoodieProcedureFilterUtils]] which evaluates SQL 
filter
+ * expressions against procedure output rows. Covers 
primitive/date/decimal/complex
+ * type conversion, the function-resolution table, numeric type coercion, 
validation
+ * and error handling.
+ */
+class TestHoodieProcedureFilterUtils extends HoodieSparkProcedureTestBase {
+
+  private def schemaOf(fields: (String, DataType)*): StructType =
+    StructType(fields.map { case (n, dt) => StructField(n, dt, nullable = 
true) })
+
+  private def keep(rows: Seq[Row], expr: String, schema: StructType): Seq[Row] 
=
+    HoodieProcedureFilterUtils.evaluateFilter(rows, expr, schema, spark)
+
+  // A rich scalar schema reused across the function tests.
+  private val scalarSchema = schemaOf(
+    "id" -> IntegerType,
+    "name" -> StringType,
+    "price" -> DoubleType,
+    "ts" -> LongType,
+    "flag" -> BooleanType,
+    "neg" -> IntegerType,
+    "d" -> DateType,
+    "t" -> TimestampType)
+
+  private val scalarRows = Seq(
+    Row(1, "a1", 10.0d, 1000L, true, -5, Date.valueOf("2024-03-15"), 
Timestamp.valueOf("2024-03-15 12:30:00")),
+    Row(2, "b2", 20.0d, 2000L, false, -7, Date.valueOf("2023-01-02"), 
Timestamp.valueOf("2023-01-02 05:00:00")))
+
+  test("evaluateFilter returns all rows for null / blank filter") {
+    assertResult(scalarRows)(keep(scalarRows, null, scalarSchema))
+    assertResult(scalarRows)(keep(scalarRows, "", scalarSchema))
+    assertResult(scalarRows)(keep(scalarRows, "   ", scalarSchema))
+  }
+
+  test("evaluateFilter handles comparison operators and boolean columns") {
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "id = 1", 
scalarSchema))
+    assertResult(Seq(scalarRows(1)))(keep(scalarRows, "id > 1", scalarSchema))
+    assertResult(2)(keep(scalarRows, "id >= 1", scalarSchema).length)
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "id < 2", 
scalarSchema))
+    assertResult(2)(keep(scalarRows, "id <= 2", scalarSchema).length)
+    assertResult(Seq(scalarRows(1)))(keep(scalarRows, "id != 1", scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "name = 'a1'", 
scalarSchema))
+    // The literal must match the column type, so use an explicit double 
literal here; the plain
+    // 15.0 (decimal) form is pinned in the numeric-coercion test below.
+    assertResult(Seq(scalarRows(1)))(keep(scalarRows, "price > 15.0d", 
scalarSchema))
+    // Bare boolean column evaluates to a Boolean result directly.
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "flag", scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "flag = true", 
scalarSchema))
+  }
+
+  test("evaluateFilter coerces Long columns against integer literals") {
+    // Exercises applyTypeCoercion for every comparison operator (Long 
boundRef vs Int literal).
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "ts = 1000", 
scalarSchema))
+    assertResult(Seq(scalarRows(1)))(keep(scalarRows, "ts > 1500", 
scalarSchema))
+    assertResult(Seq(scalarRows(1)))(keep(scalarRows, "ts >= 2000", 
scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "ts < 2000", 
scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "ts <= 1000", 
scalarSchema))
+    // Known limitation: the coercion narrows the Long column to Int instead 
of widening the Int
+    // literal, so a Long value beyond Int range never matches (wrong results 
under non-ANSI Spark,
+    // swallowed overflow error under ANSI). Pinned here so a fix flips this 
assertion; see #19632.
+    val bigRow = Seq(Row(3, "c3", 30.0d, 3000000000L, true, -9,
+      Date.valueOf("2024-03-16"), Timestamp.valueOf("2024-03-16 12:30:00")))
+    assertResult(Seq.empty)(keep(bigRow, "ts > 2000", scalarSchema))
+    // Known limitation: the coercion only matches column-on-left, so a 
literal-on-left comparison
+    // never coerces and drops every row instead of mirroring the equivalent 
column-on-left filter.
+    // Pinned here so a fix flips these assertions; see #19632.
+    assertResult(Seq.empty)(keep(scalarRows, "1500 < ts", scalarSchema))
+    assertResult(Seq.empty)(keep(scalarRows, "1000 = ts", scalarSchema))
+  }
+
+  test("evaluateFilter does not coerce other numeric column/literal type 
pairs") {
+    // Known limitation: applyTypeCoercion only special-cases a Long column 
against an Int literal.
+    // Every other numeric column/literal pair is left alone, so the 
mismatched comparison fails to
+    // evaluate; the per-row Try swallows the failure and drops the row. The 
filter therefore
+    // returns no rows instead of erroring on the type mismatch.
+    // Pinned here so a fix flips the Seq.empty assertions; see #19632.
+    val schema = schemaOf(
+      "f" -> FloatType,
+      "sh" -> ShortType,
+      "by" -> ByteType,
+      "dec" -> DecimalType(10, 2))
+    val rows = Seq(Row(2.5f, 3.toShort, 4.toByte, new 
java.math.BigDecimal("3.00")))
+
+    // Literals whose parsed type already matches the column type evaluate 
correctly.
+    assertResult(rows)(keep(rows, "f > 1.0f", schema))
+    assertResult(rows)(keep(rows, "dec > 1.00", schema))
+
+    // Mismatched literal types no-match even though the values would satisfy 
the predicate.
+    assertResult(Seq.empty)(keep(rows, "sh = 3", schema))
+    assertResult(Seq.empty)(keep(rows, "by = 4", schema))
+    assertResult(Seq.empty)(keep(rows, "f > 1.0d", schema))
+    assertResult(Seq.empty)(keep(rows, "dec > 1", schema))
+    // Same gap for a double column: a plain 15.0 parses as decimal, not 
double.
+    assertResult(Seq.empty)(keep(scalarRows, "price > 15.0", scalarSchema))
+  }
+
+  test("evaluateFilter silently drops rows for functions outside the 
resolution table") {
+    // Known limitation: a function missing from the resolution table falls 
through as an
+    // UnresolvedFunction. validateFilterExpression only checks column 
references, so nothing
+    // rejects it; instead evaluation fails per row and the row is dropped, 
which looks like an
+    // empty result rather than an error. Pinned here so a fix flips these; 
see #19638.
+    assertResult(Seq.empty)(keep(scalarRows, "concat(name, 'x') = 'a1x'", 
scalarSchema))
+    assertResult(Seq.empty)(keep(scalarRows, "instr(name, 'a') = 1", 
scalarSchema))
+    assertResult(Right(()))(
+      HoodieProcedureFilterUtils.validateFilterExpression("concat(name, 'x') = 
'a1x'", scalarSchema, spark))
+    // if() is parsed as a function call and hits the same gap, while the 
equivalent CASE WHEN is
+    // lowered by the parser without an UnresolvedFunction and evaluates fine.
+    assertResult(Seq.empty)(keep(scalarRows, "if(name = 'a1', true, false)", 
scalarSchema))
+    assertResult(Seq(scalarRows.head))(
+      keep(scalarRows, "case when name = 'a1' then true else false end", 
scalarSchema))
+    // Control: a function that is in the resolution table resolves and 
matches.
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "upper(name) = 'A1'", 
scalarSchema))
+  }
+
+  test("evaluateFilter handles AND / OR / NOT / IN / BETWEEN") {
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "id = 1 AND flag = 
true", scalarSchema))
+    assertResult(2)(keep(scalarRows, "id = 1 OR id = 2", scalarSchema).length)
+    assertResult(Seq(scalarRows(1)))(keep(scalarRows, "NOT (id = 1)", 
scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "id IN (1, 3)", 
scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "name IN ('a1', 'x')", 
scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "id BETWEEN 1 AND 1", 
scalarSchema))
+  }
+
+  test("evaluateFilter resolves string functions") {
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "upper(name) = 'A1'", 
scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "lower(name) = 'a1'", 
scalarSchema))
+    assertResult(2)(keep(scalarRows, "length(name) = 2", scalarSchema).length)
+    assertResult(2)(keep(scalarRows, "len(name) = 2", scalarSchema).length)
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "trim(name) = 'a1'", 
scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "ltrim(name) = 'a1'", 
scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "rtrim(name) = 'a1'", 
scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "substr(name, 1, 1) = 
'a'", scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "substring(name, 1, 1) 
= 'a'", scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "name LIKE 'a%'", 
scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "regexp_like(name, 
'^a')", scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "regexp_extract(name, 
'([a-z]+)', 1) = 'a'", scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "coalesce(name, 'x') = 
'a1'", scalarSchema))
+  }
+
+  test("evaluateFilter resolves numeric and cast functions") {
+    // The util only special-cases a long column vs an integer literal; every 
other numeric
+    // comparison relies on the literal already matching the expression's 
result type. So the
+    // literals below are typed to match: round/double yield double, 
ceil/floor/long yield long.
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "abs(neg) = 5", 
scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "round(price) = 
10.0d", scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "round(price, 1) = 
10.0d", scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "ceil(price) = 10L", 
scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "floor(price) = 10L", 
scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "int(price) = 10", 
scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "long(id) = 1L", 
scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "double(id) = 1.0d", 
scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "string(id) = '1'", 
scalarSchema))
+  }
+
+  test("evaluateFilter resolves date functions") {
+    // These operate on the date column and need no session time zone, so they 
evaluate directly.
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "year(d) = 2024", 
scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "month(d) = 3", 
scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "day(d) = 15", 
scalarSchema))
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "dayofmonth(d) = 15", 
scalarSchema))
+    assertResult(2)(keep(scalarRows, "datediff(d, d) = 0", 
scalarSchema).length)
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "datediff(d, 
date'2024-03-14') = 1", scalarSchema))
+  }
+
+  test("evaluateFilter cannot evaluate time-zone-aware timestamp functions") {
+    // The util binds and evaluates expressions without running Spark's 
analyzer, so time-zone-aware
+    // expressions never receive a resolved time zone and fail to evaluate; 
the filter then treats the
+    // row as a non-match rather than throwing. This documents that 
hour()/date_format() on a timestamp
+    // are unsupported here (unlike the date functions above, which are 
time-zone independent).
+    assertResult(Seq.empty)(keep(scalarRows, "hour(t) = 12", scalarSchema))
+    assertResult(Seq.empty)(keep(scalarRows, "date_format(t, 'yyyy') = 
'2024'", scalarSchema))
+  }
+
+  test("evaluateFilter maps a string result of true / false to a boolean 
decision") {
+    // string(flag) yields the literal strings "true"/"false", exercising the 
string->boolean branch.
+    assertResult(Seq(scalarRows.head))(keep(scalarRows, "string(flag)", 
scalarSchema))
+  }
+
+  test("evaluateFilter treats non-boolean-valued expressions as no-match") {
+    // A bare string/int column produces a value that is neither Boolean nor 
"true"/"false".
+    assertResult(Seq.empty)(keep(scalarRows, "name", scalarSchema))
+    assertResult(Seq.empty)(keep(scalarRows, "id", scalarSchema))
+  }
+
+  test("evaluateFilter handles null values and IS [NOT] NULL") {
+    val schema = schemaOf("id" -> IntegerType, "name" -> StringType)
+    val rows = Seq(Row(1, "a1"), Row(2, null))
+    assertResult(Seq(rows(1)))(keep(rows, "isnull(name)", schema))
+    assertResult(Seq(rows.head))(keep(rows, "isnotnull(name)", schema))
+    assertResult(Seq(rows(1)))(keep(rows, "name IS NULL", schema))
+    assertResult(Seq(rows.head))(keep(rows, "name IS NOT NULL", schema))
+    // A null-valued bare expression resolves to a false decision.
+    assertResult(Seq.empty)(keep(Seq(rows(1)), "name", schema))
+  }
+
+  test("evaluateFilter converts map columns and resolves map functions") {
+    import scala.collection.JavaConverters._
+    val schema = schemaOf("id" -> IntegerType,
+      "mScala" -> MapType(StringType, IntegerType),
+      "mJava" -> MapType(StringType, IntegerType))
+    val javaMap = Map("a" -> 1, "b" -> 2).asJava
+    val rows = Seq(Row(1, Map("a" -> 1, "b" -> 2), javaMap))
+    assertResult(rows)(keep(rows, "size(mScala) = 2", schema))
+    assertResult(rows)(keep(rows, "size(mJava) = 2", schema))
+    assertResult(rows)(keep(rows, "array_contains(map_keys(mScala), 'a')", 
schema))
+    assertResult(rows)(keep(rows, "array_contains(map_values(mScala), 1)", 
schema))
+    assertResult(Seq.empty)(keep(rows, "size(mScala) = 5", schema))
+  }
+
+  test("evaluateFilter converts struct columns and resolves IS [NOT] NULL on 
them") {
+    val schema = schemaOf("id" -> IntegerType,
+      "st" -> StructType(Seq(StructField("x", IntegerType), StructField("y", 
StringType))))
+    val rows = Seq(Row(1, Row(10, "p")), Row(2, null))
+    assertResult(Seq(rows.head))(keep(rows, "isnotnull(st)", schema))
+    assertResult(Seq(rows(1)))(keep(rows, "isnull(st)", schema))
+  }
+
+  test("evaluateFilter converts array / decimal / binary / uuid / java-time 
columns without error") {
+    import scala.collection.JavaConverters._
+    val schema = schemaOf(
+      "id" -> IntegerType,
+      "arrScala" -> ArrayType(IntegerType),
+      "arrList" -> ArrayType(IntegerType),
+      "arrArray" -> ArrayType(IntegerType),
+      "dec" -> DecimalType(10, 2),
+      "decScala" -> DecimalType(10, 2),
+      "bin" -> BinaryType,
+      "uuidCol" -> StringType,
+      "inst" -> TimestampType,
+      "ld" -> DateType,
+      "ldt" -> TimestampType)
+    val row = Row(
+      1,
+      Seq(1, 2, 3),
+      List(1, 2, 3).map(Int.box).asJava,
+      Array(1, 2, 3),
+      new java.math.BigDecimal("12.50"),
+      scala.math.BigDecimal("34.75"),
+      Array[Byte](1, 2, 3),
+      java.util.UUID.randomUUID(),
+      java.time.Instant.parse("2024-03-15T12:30:00Z"),
+      java.time.LocalDate.of(2024, 3, 15),
+      java.time.LocalDateTime.of(2024, 3, 15, 12, 30, 0))
+    val rows = Seq(row)
+    // Filtering on the scalar column converts every field of the row, 
exercising each
+    // complex-type conversion branch; the row is retained.
+    assertResult(rows)(keep(rows, "id = 1", schema))
+    // Decimal / binary / uuid / java-time values survive conversion and are 
non-null.
+    assertResult(rows)(keep(rows, "isnotnull(dec) AND isnotnull(decScala)", 
schema))
+    assertResult(rows)(keep(rows, "isnotnull(bin) AND isnotnull(uuidCol)", 
schema))
+    assertResult(rows)(keep(rows, "isnotnull(inst) AND isnotnull(ld) AND 
isnotnull(ldt)", schema))
+    // Known limitation: array values are converted to a plain Array instead 
of Catalyst ArrayData,
+    // so every array predicate (even isnotnull) fails to evaluate and drops 
the row instead of
+    // matching. Pinned here so a fix flips these assertions; see #19633.
+    assertResult(Seq.empty)(keep(rows, "size(arrScala) >= 0", schema))
+    assertResult(Seq.empty)(keep(rows, "isnotnull(arrScala)", schema))
+  }
+
+  test("evaluateFilter throws IllegalArgumentException on unparseable 
expression") {
+    val e = intercept[IllegalArgumentException] {
+      keep(scalarRows, "id >< 1", scalarSchema)
+    }
+    assert(e.getMessage.contains("Failed to parse or evaluate filter 
expression"))
+  }
+
+  test("validateFilterExpression accepts valid references and rejects unknown 
ones") {
+    assertResult(Right(()))(
+      HoodieProcedureFilterUtils.validateFilterExpression("id > 1 AND name = 
'a1'", scalarSchema, spark))
+    assertResult(Right(()))(
+      HoodieProcedureFilterUtils.validateFilterExpression(null, scalarSchema, 
spark))
+    assertResult(Right(()))(
+      HoodieProcedureFilterUtils.validateFilterExpression("   ", scalarSchema, 
spark))
+
+    val invalidCol = 
HoodieProcedureFilterUtils.validateFilterExpression("missing_col > 1", 
scalarSchema, spark)
+    assert(invalidCol.isLeft)
+    val invalidColMsg = invalidCol.fold(identity, _ => "")
+    assert(invalidColMsg.contains("Invalid column references"))
+    assert(invalidColMsg.contains("missing_col"))
+
+    val parseError = HoodieProcedureFilterUtils.validateFilterExpression("id 
>< 1", scalarSchema, spark)
+    assert(parseError.isLeft)
+    assert(parseError.fold(identity, _ => "").contains("Invalid filter 
expression"))
+  }
+}
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestProcedureParameterImpl.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestProcedureParameterImpl.scala
new file mode 100644
index 000000000000..fac0db989444
--- /dev/null
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestProcedureParameterImpl.scala
@@ -0,0 +1,63 @@
+/*
+ * 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.spark.sql.hudi.command.procedures.ProcedureParameter
+import org.apache.spark.sql.types.DataTypes
+import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, 
assertNotEquals, assertTrue}
+import org.scalatest.funsuite.AnyFunSuite
+
+/**
+ * Unit tests for ProcedureParameterImpl.equals. The implementation used to 
call this == other for
+ * the identity check, which dispatches straight back into equals and recurses 
until it overflows
+ * the stack, and it cast the argument before the null/type guard, so a 
foreign argument threw
+ * ClassCastException. Both are self-contained checks, so no Spark session is 
needed.
+ */
+class TestProcedureParameterImpl extends AnyFunSuite {
+
+  test("Test ProcedureParameterImpl equals identity, foreign types and null") {
+    val param = ProcedureParameter.optional(0, "table", DataTypes.StringType, 
"default")
+
+    // Self comparison must terminate instead of recursing into equals.
+    assertTrue(param == param)
+    assertTrue(param.equals(param))
+
+    // A foreign type and null are rejected by the guard rather than blowing 
up on the cast.
+    assertFalse(param.equals("notAParam"))
+    assertFalse(param.equals(null))
+  }
+
+  test("Test ProcedureParameterImpl equals and hashCode over field values") {
+    val required = ProcedureParameter.required(2, "instant", 
DataTypes.StringType)
+    val sameAsRequired = ProcedureParameter.required(2, "instant", 
DataTypes.StringType)
+    assertEquals(required, sameAsRequired)
+    assertEquals(required.hashCode(), sameAsRequired.hashCode())
+
+    val optional = ProcedureParameter.optional(1, "dry_run", 
DataTypes.BooleanType, true)
+    val sameAsOptional = ProcedureParameter.optional(1, "dry_run", 
DataTypes.BooleanType, true)
+    assertEquals(optional, sameAsOptional)
+    assertEquals(optional.hashCode(), sameAsOptional.hashCode())
+
+    // One differing field at a time.
+    assertNotEquals(optional, ProcedureParameter.optional(2, "dry_run", 
DataTypes.BooleanType, true))
+    assertNotEquals(optional, ProcedureParameter.optional(1, "backup", 
DataTypes.BooleanType, true))
+    assertNotEquals(optional, ProcedureParameter.optional(1, "dry_run", 
DataTypes.StringType, true))
+    assertNotEquals(optional, ProcedureParameter.optional(1, "dry_run", 
DataTypes.BooleanType, false))
+    assertNotEquals(optional, ProcedureParameter.required(1, "dry_run", 
DataTypes.BooleanType))
+  }
+}
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestRepairClusteringPlanProcedure.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestRepairClusteringPlanProcedure.scala
index 9c1d87904b4d..bdf75b688eca 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestRepairClusteringPlanProcedure.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestRepairClusteringPlanProcedure.scala
@@ -18,7 +18,7 @@
 package org.apache.spark.sql.hudi.procedure
 
 import org.apache.hudi.avro.model.HoodieClusteringPlan
-import org.apache.hudi.common.table.timeline.HoodieTimeline
+import org.apache.hudi.common.table.timeline.{HoodieInstant, HoodieTimeline}
 import org.apache.hudi.common.util.ClusteringUtils
 import org.apache.hudi.storage.StoragePath
 import org.apache.hudi.testutils.HoodieClientTestUtils.createMetaClient
@@ -36,35 +36,7 @@ class TestRepairClusteringPlanProcedure extends 
HoodieSparkProcedureTestBase {
       withSQLConf("hoodie.parquet.small.file.limit" -> "0") {
         val tableName = generateTableName
         val basePath = s"${tmp.getCanonicalPath}/$tableName"
-
-        spark.sql(
-          s"""
-             |create table $tableName (
-             |  id int,
-             |  name string,
-             |  price double,
-             |  ts long,
-             |  dt string
-             |) using hudi
-             | tblproperties (
-             |  primaryKey = 'id',
-             |  type = 'cow',
-             |  orderingFields = 'ts',
-             |  hoodie.metadata.enable = 'false'
-             | )
-             | partitioned by (dt)
-             | location '$basePath'
-             |""".stripMargin)
-
-        insertRows(tableName, "2026-01-27", 1)
-        insertRows(tableName, "2026-01-27", 4)
-        insertRows(tableName, "2026-01-27", 7)
-
-        runClustering(tableName, "schedule")
-        var metadata = getLatestRequestedClusteringPlan(basePath)
-        val instant1 = metadata.getLeft
-        val plannedFiles = getPlanDataFiles(metadata.getRight)
-        assertTrue(plannedFiles.size > 1, s"Expected more than one planned 
file, but got $plannedFiles")
+        val (instant1, plannedFiles) = 
createTableWithPendingClusteringPlan(tableName, basePath)
 
         val fileToRemove = plannedFiles.head
         val dryRunRows = spark.sql(
@@ -78,8 +50,7 @@ class TestRepairClusteringPlanProcedure extends 
HoodieSparkProcedureTestBase {
             s"op => 'delete', invalid_parquet_files => '$fileToRemove', 
dry_run => false)").collect()
         assertRepairResult(deleteRows, instant1.requestedTime, fileToRemove, 
"REMOVED_FROM_PLAN", deleted = false, "USER_REQUESTED")
 
-        metadata = getLatestRequestedClusteringPlan(basePath)
-        assertFalse(getPlanDataFiles(metadata.getRight).contains(fileToRemove))
+        
assertFalse(getPlanDataFiles(getLatestRequestedClusteringPlan(basePath).getRight).contains(fileToRemove))
 
         var metaClient = createMetaClient(spark, basePath)
         assertTrue(metaClient.getStorage.exists(new StoragePath(fileToRemove)))
@@ -99,9 +70,9 @@ class TestRepairClusteringPlanProcedure extends 
HoodieSparkProcedureTestBase {
         insertRows(tableName, "2026-01-28", 16)
 
         runClustering(tableName, "schedule")
-        metadata = getLatestRequestedClusteringPlan(basePath)
-        val instant2 = metadata.getLeft
-        val corruptFile = getPlanDataFiles(metadata.getRight).head
+        val metadata2 = getLatestRequestedClusteringPlan(basePath)
+        val instant2 = metadata2.getLeft
+        val corruptFile = getPlanDataFiles(metadata2.getRight).head
         metaClient = createMetaClient(spark, basePath)
         replaceWithEmptyFile(metaClient.getStorage, new 
StoragePath(corruptFile))
 
@@ -110,8 +81,7 @@ class TestRepairClusteringPlanProcedure extends 
HoodieSparkProcedureTestBase {
             s"op => 'validate_delete', need_delete => true, dry_run => 
false)").collect()
         assertRepairResult(validateRows, instant2.requestedTime, corruptFile, 
"REMOVED_FROM_PLAN", deleted = true, "NOT_PARQUET_FILE")
 
-        metadata = getLatestRequestedClusteringPlan(basePath)
-        assertFalse(getPlanDataFiles(metadata.getRight).contains(corruptFile))
+        
assertFalse(getPlanDataFiles(getLatestRequestedClusteringPlan(basePath).getRight).contains(corruptFile))
         metaClient = createMetaClient(spark, basePath)
         assertFalse(metaClient.getStorage.exists(new StoragePath(corruptFile)))
       }
@@ -122,6 +92,126 @@ class TestRepairClusteringPlanProcedure extends 
HoodieSparkProcedureTestBase {
     
assertNotNull(org.apache.spark.sql.hudi.command.procedures.HoodieProcedures.newBuilder("repair_clustering_plan"))
   }
 
+  test("Test repair_clustering_plan validate_delete, unsupported op and 
allow_empty_plan guard") {
+    withTempDir { tmp =>
+      withSQLConf("hoodie.parquet.small.file.limit" -> "0") {
+        val tableName = generateTableName
+        val basePath = s"${tmp.getCanonicalPath}/$tableName"
+        val (instant, plannedFiles) = 
createTableWithPendingClusteringPlan(tableName, basePath)
+
+        // validate_delete over a healthy plan finds no corrupt files, so no 
candidates are returned.
+        // Anchor the negative result: every planned file exists and is 
readable on storage.
+        val metaClient = createMetaClient(spark, basePath)
+        plannedFiles.foreach(file => 
assertTrue(metaClient.getStorage.exists(new StoragePath(file)),
+          s"Expected planned file $file to exist"))
+        val healthyRows = spark.sql(
+          s"call repair_clustering_plan(table => '$tableName', instant => 
'${instant.requestedTime}', " +
+            s"op => 'validate_delete')").collect()
+        assertEquals(0, healthyRows.length)
+
+        // An unknown operation is rejected.
+        checkExceptionContain(
+          s"call repair_clustering_plan(table => '$tableName', instant => 
'${instant.requestedTime}', op => 'bogus')")(
+          "Unsupported operation")
+
+        // A candidate that is not part of the plan is reported as 
NOT_FOUND_IN_PLAN and, because
+        // deletion only applies to the (candidates intersect plan) set, the 
file is never deleted
+        // even with need_delete => true.
+        insertRows(tableName, "2026-01-28", 10)
+        val foreign = spark.sql(
+          s"select distinct _hoodie_partition_path, _hoodie_file_name from 
$tableName where dt = '2026-01-28'")
+          .collect().head
+        val notInPlanFile = 
s"$basePath/${foreign.getString(0)}/${foreign.getString(1)}"
+        val notInPlanRows = spark.sql(
+          s"call repair_clustering_plan(table => '$tableName', instant => 
'${instant.requestedTime}', " +
+            s"op => 'delete', invalid_parquet_files => '$notInPlanFile', 
need_delete => true, dry_run => false)").collect()
+        assertRepairResult(notInPlanRows, instant.requestedTime, 
notInPlanFile, "NOT_FOUND_IN_PLAN", deleted = false, "USER_REQUESTED")
+        assertTrue(metaClient.getStorage.exists(new 
StoragePath(notInPlanFile)),
+          s"Expected $notInPlanFile to survive a repair request it is not 
planned in")
+        assertEquals(plannedFiles.toSet, 
getPlanDataFiles(getLatestRequestedClusteringPlan(basePath).getRight).toSet)
+
+        // Removing every input group without allow_empty_plan is rejected.
+        val allFilesArg = plannedFiles.distinct.mkString(",")
+        checkExceptionContain(
+          s"call repair_clustering_plan(table => '$tableName', instant => 
'${instant.requestedTime}', " +
+            s"op => 'delete', invalid_parquet_files => '$allFilesArg')")(
+          "would remove all input groups")
+
+        // With allow_empty_plan the destructive run removes every planned 
file and rewrites the
+        // requested instant with an empty plan that still parses.
+        val emptyPlanRows = spark.sql(
+          s"call repair_clustering_plan(table => '$tableName', instant => 
'${instant.requestedTime}', " +
+            s"op => 'delete', invalid_parquet_files => '$allFilesArg', 
allow_empty_plan => true, dry_run => false)").collect()
+        assertEquals(plannedFiles.distinct.size, emptyPlanRows.length)
+        assertTrue(emptyPlanRows.forall(_.getString(2) == "REMOVED_FROM_PLAN"),
+          s"Expected all rows to be REMOVED_FROM_PLAN, but got 
${emptyPlanRows.mkString("[", ", ", "]")}")
+        val emptiedPlan = getLatestRequestedClusteringPlan(basePath)
+        assertEquals(instant.requestedTime, emptiedPlan.getLeft.requestedTime)
+        assertTrue(getPlanDataFiles(emptiedPlan.getRight).isEmpty,
+          s"Expected the rewritten plan to have no data files, but got 
${getPlanDataFiles(emptiedPlan.getRight)}")
+      }
+    }
+  }
+
+  test("Test repair_clustering_plan with backup disabled") {
+    withTempDir { tmp =>
+      withSQLConf("hoodie.parquet.small.file.limit" -> "0") {
+        val tableName = generateTableName
+        val basePath = s"${tmp.getCanonicalPath}/$tableName"
+        val (instant, plannedFiles) = 
createTableWithPendingClusteringPlan(tableName, basePath)
+
+        // With backup => false the requested instant file is deleted and 
rewritten in place with no
+        // copy taken first, which is a distinct arm from the default backup 
=> true path the other
+        // tests cover. The restore-on-failure arm (a backed up instant 
restored after the rewrite
+        // throws) remains uncovered, since it needs an injected storage 
failure.
+        val fileToRemove = plannedFiles.head
+        val rows = spark.sql(
+          s"call repair_clustering_plan(table => '$tableName', instant => 
'${instant.requestedTime}', " +
+            s"op => 'delete', invalid_parquet_files => '$fileToRemove', backup 
=> false, dry_run => false)").collect()
+        assertRepairResult(rows, instant.requestedTime, fileToRemove, 
"REMOVED_FROM_PLAN", deleted = false, "USER_REQUESTED")
+
+        val repairedPlan = getLatestRequestedClusteringPlan(basePath)
+        assertEquals(instant.requestedTime, repairedPlan.getLeft.requestedTime)
+        
assertFalse(getPlanDataFiles(repairedPlan.getRight).contains(fileToRemove))
+
+        val metaClient = createMetaClient(spark, basePath)
+        assertFalse(metaClient.getStorage.exists(new 
StoragePath(metaClient.getMetaPath, ".repair")),
+          "Expected no backup under .repair when backup => false")
+      }
+    }
+  }
+
+  private def createTableWithPendingClusteringPlan(tableName: String, 
basePath: String): (HoodieInstant, Seq[String]) = {
+    spark.sql(
+      s"""
+         |create table $tableName (
+         |  id int,
+         |  name string,
+         |  price double,
+         |  ts long,
+         |  dt string
+         |) using hudi
+         | tblproperties (
+         |  primaryKey = 'id',
+         |  type = 'cow',
+         |  orderingFields = 'ts',
+         |  hoodie.metadata.enable = 'false'
+         | )
+         | partitioned by (dt)
+         | location '$basePath'
+         |""".stripMargin)
+
+    insertRows(tableName, "2026-01-27", 1)
+    insertRows(tableName, "2026-01-27", 4)
+    insertRows(tableName, "2026-01-27", 7)
+
+    runClustering(tableName, "schedule")
+    val metadata = getLatestRequestedClusteringPlan(basePath)
+    val plannedFiles = getPlanDataFiles(metadata.getRight)
+    assertTrue(plannedFiles.size > 1, s"Expected more than one planned file, 
but got $plannedFiles")
+    (metadata.getLeft, plannedFiles)
+  }
+
   private def runClustering(tableName: String,
                             operation: String,
                             instant: Option[String] = None): Unit = {
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestShowCleansProcedures.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestShowCleansProcedures.scala
index c35d96dacfd2..9fac4df2f072 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestShowCleansProcedures.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestShowCleansProcedures.scala
@@ -18,6 +18,11 @@
 package org.apache.spark.sql.hudi.procedure
 
 import org.apache.hudi.HoodieSparkUtils
+import org.apache.hudi.testutils.HoodieClientTestUtils.createMetaClient
+
+import java.io.IOException
+
+import scala.collection.JavaConverters._
 
 class TestShowCleansProcedures extends HoodieSparkProcedureTestBase {
 
@@ -213,78 +218,6 @@ class TestShowCleansProcedures extends 
HoodieSparkProcedureTestBase {
     }
   }
 
-  test("Test show procedures with showArchived parameter") {
-    withTempDir { tmp =>
-      Seq("COPY_ON_WRITE").foreach { tableType =>
-        val tableName = generateTableName
-        val tablePath = s"${tmp.getCanonicalPath}/$tableName"
-        val extraConf = if (HoodieSparkUtils.gteqSpark3_4) {
-          Map("spark.sql.defaultColumn.enabled" -> "false")
-        } else {
-          Map.empty[String, String]
-        }
-        withSQLConf(extraConf.toSeq: _*) {
-          spark.sql(
-            s"""
-               |create table $tableName (
-               | id int,
-               | name string,
-               | price double,
-               | ts long
-               | ) using hudi
-               | location '$tablePath'
-               | tblproperties (
-               |   primaryKey = 'id',
-               |   type = '$tableType',
-               |   preCombineField = 'ts'
-               | )
-               |""".stripMargin)
-
-          spark.sql(s"insert into $tableName values(1, 'a1', 10, 1000)")
-          spark.sql(s"insert into $tableName values(2, 'a2', 20, 2000)")
-          spark.sql(s"update $tableName set price = 11 where id = 1")
-
-          spark.sql(s"call run_clean(table => '$tableName', retain_commits => 
1)")
-            .collect()
-
-          // showArchived=false (default - active timeline only)
-          val activeCleans = spark.sql(s"call show_cleans(table => 
'$tableName', showArchived => false)")
-            .collect()
-          spark.sql(s"call show_cleans(table => '$tableName', showArchived => 
false)").show(false)
-
-          val activePlans = spark.sql(s"call show_clean_plans(table => 
'$tableName', showArchived => false)")
-            .collect()
-          spark.sql(s"call show_clean_plans(table => '$tableName', 
showArchived => false)").show(false)
-
-          val activeMetadata = spark.sql(s"call show_cleans_metadata(table => 
'$tableName', showArchived => false)")
-            .collect()
-          spark.sql(s"call show_cleans_metadata(table => '$tableName', 
showArchived => false)").show(false)
-
-          // showArchived=true (both active + archived timelines merged)
-          val allCleans = spark.sql(s"call show_cleans(table => '$tableName', 
showArchived => true)")
-            .collect()
-          spark.sql(s"call show_cleans(table => '$tableName', showArchived => 
true)").show(false)
-
-          val allPlans = spark.sql(s"call show_clean_plans(table => 
'$tableName', showArchived => true)")
-            .collect()
-          spark.sql(s"call show_clean_plans(table => '$tableName', 
showArchived => true)").show(false)
-
-          val allMetadata = spark.sql(s"call show_cleans_metadata(table => 
'$tableName', showArchived => true)")
-            .collect()
-          spark.sql(s"call show_cleans_metadata(table => '$tableName', 
showArchived => true)").show(false)
-
-          assert(activeCleans.length >= 1, "Active timeline should have clean 
instances")
-          assert(activePlans.length >= 1, "Active timeline should have clean 
plans")
-
-          // showArchived=true should include at least the same data as active 
timeline
-          assert(allCleans.length >= activeCleans.length, "Active + Archived 
should have at least as many instances as active only")
-          assert(allPlans.length >= activePlans.length, "Active + Archived 
should have at least as many plans as active only")
-          assert(allMetadata.length >= activeMetadata.length, "Active + 
Archived should have at least as much metadata as active only")
-        }
-      }
-    }
-  }
-
   test("Test show_cleans procedures with limit parameter") {
     withSQLConf("hoodie.clean.automatic" -> "false", 
"hoodie.parquet.max.file.size" -> "10000") {
       withTempDir { tmp =>
@@ -547,4 +480,163 @@ class TestShowCleansProcedures extends 
HoodieSparkProcedureTestBase {
       }
     }
   }
+
+  test("Test show_clean_plans with an archived clean instant") {
+    withSQLConf("hoodie.clean.automatic" -> "false", 
"hoodie.archive.automatic" -> "false") {
+      withTempDir { tmp =>
+        val tableName = generateTableName
+        val tablePath = tmp.getCanonicalPath
+        spark.sql(
+          s"""
+             |create table $tableName (
+             | id int,
+             | name string,
+             | price double,
+             | ts long
+             | ) using hudi
+             | location '$tablePath'
+             | tblproperties (
+             |   primaryKey = 'id',
+             |   type = 'cow',
+             |   preCombineField = 'ts',
+             |   hoodie.metadata.enable = 'false'
+             | )
+             |""".stripMargin)
+
+        def rowCount(procedure: String, showArchived: Boolean): Int =
+          spark.sql(s"call $procedure(table => '$tableName', showArchived => 
$showArchived)").collect().length
+
+        // Six write commits with a clean in the middle: the first clean sits 
before the last
+        // commit that archival will move, so it gets archived along with 
those commits, while
+        // the second clean stays on the active timeline.
+        spark.sql(s"insert into $tableName values(1, 'a1', 10, 1000)")
+        spark.sql(s"update $tableName set price = 11 where id = 1")
+        spark.sql(s"update $tableName set price = 12 where id = 1")
+        spark.sql(s"call run_clean(table => '$tableName', retain_commits => 
1)").collect()
+        spark.sql(s"update $tableName set price = 13 where id = 1")
+        spark.sql(s"update $tableName set price = 14 where id = 1")
+        spark.sql(s"update $tableName set price = 15 where id = 1")
+        spark.sql(s"call run_clean(table => '$tableName', retain_commits => 
1)").collect()
+
+        // Nothing has been archived yet, so showArchived => true here only 
exercises the merge of
+        // the active timeline with an empty archived one. All three 
procedures succeed and see
+        // both cleans, which is what makes the failures asserted after 
archival attributable to
+        // the archived instants themselves rather than to the merged read 
path. Two rows for
+        // show_cleans_metadata as well, since that procedure emits one row 
per partition per
+        // clean and this table is not partitioned.
+        assertResult(2)(rowCount("show_cleans", showArchived = true))
+        assertResult(2)(rowCount("show_cleans_metadata", showArchived = true))
+        assertResult(2)(rowCount("show_clean_plans", showArchived = true))
+
+        spark.sql(s"call archive_commits(table => '$tableName', min_commits => 
2, max_commits => 3," +
+          " retain_commits => 1, enable_metadata => false)").collect()
+
+        // Precondition: archival must have split the two cleans across the 
two timelines,
+        // otherwise the archived branch below is never exercised and the test 
passes vacuously.
+        val metaClient = createMetaClient(spark, tablePath)
+        val archivedCleans = metaClient.getArchivedTimeline.getCleanerTimeline
+          .getInstants.asScala.map(_.requestedTime).toSeq
+        val activeCleans = metaClient.getActiveTimeline.getCleanerTimeline
+          .getInstants.asScala.map(_.requestedTime).toSeq
+        assert(archivedCleans.length == 1, s"expected exactly 1 archived 
clean, got $archivedCleans")
+        assert(activeCleans.length == 1, s"expected exactly 1 active clean, 
got $activeCleans")
+        assert(archivedCleans.head.compareTo(activeCleans.head) < 0,
+          "the archived clean must be the older of the two")
+
+        // Sibling-procedure controls, pinning what the other two procedures 
do with the very same
+        // archived clean. On the active timeline all three agree: one row for 
the one active
+        // clean. With showArchived => true they diverge, and neither sibling 
is correct today.
+        assertResult(1)(rowCount("show_cleans", showArchived = false))
+        assertResult(1)(rowCount("show_cleans_metadata", showArchived = false))
+        assertResult(1)(rowCount("show_clean_plans", showArchived = false))
+
+        // The other half of #19639, which covers all three clean procedures. 
show_cleans and its
+        // show_cleans_metadata variant do route to getArchivedTimeline, but 
the archived instants
+        // carry no content there, so readCleanMetadata cannot deserialize 
them and the call fails
+        // outright rather than degrading to a partial row. Same 
missing-archived-content cause as
+        // the all-null plan rows asserted below, just a harsher symptom. 
Pinned as observed.
+        Seq("show_cleans", "show_cleans_metadata").foreach { procedure =>
+          val e = intercept[IOException](rowCount(procedure, showArchived = 
true))
+          assert(e.getMessage.contains(archivedCleans.head),
+            s"$procedure over the archived timeline should fail on the 
archived clean" +
+              s" ${archivedCleans.head}, but failed with: ${e.getMessage}")
+        }
+
+        val plans = spark.sql(s"call show_clean_plans(table => '$tableName', 
showArchived => true)").collect()
+        assertResult(2)(plans.length)
+        val planTimes = plans.map(_.getString(0)).mkString(", ")
+        val activePlan = plans.find(_.getString(0) == activeCleans.head)
+          .getOrElse(fail(s"no plan row for the active clean 
${activeCleans.head}, got plan_times: $planTimes"))
+        val archivedPlan = plans.find(_.getString(0) == archivedCleans.head)
+          .getOrElse(fail(s"no plan row for the archived clean 
${archivedCleans.head}, got plan_times: $planTimes"))
+
+        // Fields that come from the cleaner plan itself, resolved by name off 
the row schema so
+        // that a change in output-schema ordering cannot silently re-point 
these assertions.
+        // extra_metadata is left out: it is null for both rows, so it does 
not discriminate.
+        val planFields = Seq(
+          "earliest_instant_to_retain",
+          "last_completed_commit_timestamp",
+          "policy",
+          "version",
+          "total_partitions_to_clean",
+          "total_partitions_to_delete")
+
+        // The active clean plan is read correctly.
+        assertResult("COMPLETED")(activePlan.getString(1))
+        assertResult("clean")(activePlan.getString(2))
+        planFields.foreach { name =>
+          assert(!activePlan.isNullAt(activePlan.fieldIndex(name)), s"active 
clean plan should have a non-null $name")
+        }
+
+        // Known limitation, see #19639: getCleanerPlans collects the archived 
clean instants but
+        // then hands every instant to processCleanPlan against the ACTIVE 
timeline, so the
+        // archived instant's .clean.requested file is not found, the read 
falls back to
+        // createErrorRow and every plan field comes back null. Only 
plan_time/state/action
+        // survive, because those are taken from the instant and not from the 
plan. A fix has to
+        // read the archived instant's own content and would flip the null 
assertions below to
+        // non-null; merely routing to getArchivedTimeline the way the sibling 
ShowCleansProcedure
+        // does is not enough, since that path throws today (pinned above).
+        assertResult("COMPLETED")(archivedPlan.getString(1))
+        assertResult("clean")(archivedPlan.getString(2))
+        planFields.foreach { name =>
+          assert(archivedPlan.isNullAt(archivedPlan.fieldIndex(name)),
+            s"archived clean plan is expected to return a null $name today 
(#19639)")
+        }
+      }
+    }
+  }
+
+  test("Test show_clean_plans validates its inputs") {
+    withSQLConf("hoodie.clean.automatic" -> "false") {
+      withTempDir { tmp =>
+        val tableName = generateTableName
+        spark.sql(
+          s"""
+             |create table $tableName (
+             | id int,
+             | name string,
+             | price double,
+             | ts long
+             | ) using hudi
+             | location '${tmp.getCanonicalPath}'
+             | tblproperties (
+             |   primaryKey = 'id',
+             |   type = 'cow',
+             |   preCombineField = 'ts'
+             | )
+             |""".stripMargin)
+
+        // No insert needed: both validations run before any table data is 
read.
+        // A non-positive limit is rejected.
+        checkExceptionContain(s"call show_clean_plans(table => '$tableName', 
limit => 0)")(
+          "Limit must be positive")
+
+        // A filter that references an unknown column is rejected before the 
plans are read; the
+        // column-reference message discriminates this branch from a filter 
parse failure.
+        checkExceptionContain(
+          s"""call show_clean_plans(table => '$tableName', filter => 
"nonexistent_col > 1")""")(
+          "Invalid column references: nonexistent_col")
+      }
+    }
+  }
 }
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestValidateHoodieSyncProcedure.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestValidateHoodieSyncProcedure.scala
new file mode 100644
index 000000000000..2cc640e7e971
--- /dev/null
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestValidateHoodieSyncProcedure.scala
@@ -0,0 +1,254 @@
+/*
+ * 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.exception.HoodieIOException
+
+import java.sql.SQLException
+
+/**
+ * Tests for 
[[org.apache.spark.sql.hudi.command.procedures.ValidateHoodieSyncProcedure]].
+ *
+ * The "complete" / "latestPartitions" modes count records over JDBC, but they 
do not need a live
+ * Hive/JDBC endpoint to be exercised negatively: pointed at a portless URL, 
both fail fast in the
+ * driver's transport setup, which rejects the invalid port before any network 
connect. The last
+ * test pins those two failure shapes, one of which is the connection-failure 
masking bug of #19635.
+ *
+ * The other tests pass 'noop', which short-circuits the record counting 
(record counts stay 0)
+ * while still exercising the timeline comparison, the catch-up-commit 
computation and the result
+ * formatting, which is the bulk of the procedure.
+ */
+class TestValidateHoodieSyncProcedure extends HoodieSparkProcedureTestBase {
+
+  /**
+   * The throwable and its causes, in order. Null-terminated, and capped so a 
self-referential
+   * cause cannot spin forever.
+   */
+  private def causeChain(t: Throwable): Seq[Throwable] =
+    Iterator.iterate(t)(_.getCause).takeWhile(_ != null).take(16).toSeq
+
+  private def chainTypes(t: Throwable): String = 
causeChain(t).map(_.getClass.getName).mkString(" <- ")
+
+  private def createTable(tableName: String, path: String, tableType: String = 
"cow"): Unit = {
+    spark.sql(
+      s"""
+         |create table $tableName (
+         |  id int,
+         |  name string,
+         |  price double,
+         |  ts long
+         |) using hudi
+         | location '$path'
+         | tblproperties (
+         |  type = '$tableType',
+         |  primaryKey = 'id',
+         |  orderingFields = 'ts'
+         | )
+       """.stripMargin)
+  }
+
+  test("Test Call sync_validate when the target table is ahead (catch-up 
commits)") {
+    withTempDir { tmp =>
+      // Each insert must land in its own file group: the catch-up count is
+      // recordsWritten - updateRecordsWritten summed over the catch-up 
commits, so without this
+      // conf the second insert packs into the first small file, rewrites the 
copied first record
+      // and the count becomes 3 instead of 2.
+      withSQLConf("hoodie.parquet.small.file.limit" -> "0") {
+        val srcTable = generateTableName
+        val dstTable = generateTableName
+        createTable(srcTable, s"${tmp.getCanonicalPath}/$srcTable")
+        // The source table has a single, earlier commit.
+        spark.sql(s"insert into $srcTable select 1, 'a1', 10, 1000")
+
+        createTable(dstTable, s"${tmp.getCanonicalPath}/$dstTable")
+        // The destination table receives later commits, so it is ahead of the 
source.
+        spark.sql(s"insert into $dstTable select 1, 'a1', 10, 1000")
+        spark.sql(s"insert into $dstTable select 2, 'a2', 20, 2000")
+
+        val result = spark.sql(
+          s"""call sync_validate(src_table => '$srcTable', dst_table => 
'$dstTable',
+             | mode => 'noop', hive_server_url => 'jdbc:hive2://unused', 
hive_pass => 'x')"""
+            .stripMargin).collect()
+
+        assertResult(1)(result.length)
+        // The procedure prints the unqualified table name, while 
generateTableName is db-qualified.
+        val srcName = srcTable.stripPrefix("default.")
+        val dstName = dstTable.stripPrefix("default.")
+        // The destination is ahead, so the dst-first branch is taken 
(count(dst) - count(src)) and the
+        // two catch-up commits (one insert record each) are counted. Record 
counts stay 0 in this mode.
+        // "Catach up" mirrors the typo in the procedure's output message.
+        assertResult(s"Count difference now is count($dstName) - 
count($srcName) == 0. Catach up count is 2")(
+          result.head.getString(0))
+      }
+    }
+  }
+
+  test("Test Call sync_validate when both tables point at the same timeline 
(no catch-up)") {
+    withTempDir { tmp =>
+      val tableName = generateTableName
+      createTable(tableName, s"${tmp.getCanonicalPath}/$tableName")
+      spark.sql(s"insert into $tableName select 1, 'a1', 10, 1000")
+
+      // Using the same table as both source and target means neither timeline 
is ahead,
+      // so no catch-up commits are found and only the count difference is 
reported.
+      val result = spark.sql(
+        s"""call sync_validate(src_table => '$tableName', dst_table => 
'$tableName',
+           | mode => 'noop', hive_server_url => 'jdbc:hive2://unused', 
hive_pass => 'x')"""
+          .stripMargin).collect()
+
+      assertResult(1)(result.length)
+      // The procedure prints the unqualified table name, while 
generateTableName is db-qualified.
+      val name = tableName.stripPrefix("default.")
+      // No catch-up suffix: the exact match pins that no commits are found 
after the (shared) latest.
+      assertResult(s"Count difference now is count($name) - count($name) == 
0")(
+        result.head.getString(0))
+    }
+  }
+
+  test("Test Call sync_validate when the source table is ahead (argument 
order)") {
+    withTempDir { tmp =>
+      withSQLConf("hoodie.parquet.small.file.limit" -> "0") {
+        val srcTable = generateTableName
+        val dstTable = generateTableName
+        createTable(dstTable, s"${tmp.getCanonicalPath}/$dstTable")
+        spark.sql(s"insert into $dstTable select 1, 'a1', 10, 1000")
+
+        createTable(srcTable, s"${tmp.getCanonicalPath}/$srcTable")
+        spark.sql(s"insert into $srcTable select 1, 'a1', 10, 1000")
+        spark.sql(s"insert into $srcTable select 2, 'a2', 20, 2000")
+
+        val result = spark.sql(
+          s"""call sync_validate(src_table => '$srcTable', dst_table => 
'$dstTable',
+             | mode => 'noop', hive_server_url => 'jdbc:hive2://unused', 
hive_pass => 'x')"""
+            .stripMargin).collect()
+
+        assertResult(1)(result.length)
+        // The procedure prints the unqualified table name, while 
generateTableName is db-qualified.
+        val srcName = srcTable.stripPrefix("default.")
+        val dstName = dstTable.stripPrefix("default.")
+        // The source is ahead, so the else branch is taken: this pins that it 
passes the source
+        // table first, i.e. count(src) - count(dst), the mirror image of the 
dst-ahead branch.
+        assertResult(s"Count difference now is count($srcName) - 
count($dstName) == 0. Catach up count is 2")(
+          result.head.getString(0))
+      }
+    }
+  }
+
+  test("Test Call sync_validate when the target table has never been written 
(sentinel commit)") {
+    withTempDir { tmp =>
+      withSQLConf("hoodie.parquet.small.file.limit" -> "0") {
+        val srcTable = generateTableName
+        val dstTable = generateTableName
+        createTable(srcTable, s"${tmp.getCanonicalPath}/$srcTable")
+        spark.sql(s"insert into $srcTable select 1, 'a1', 10, 1000")
+        spark.sql(s"insert into $srcTable select 2, 'a2', 20, 2000")
+
+        // The destination is created but never written, so its commits 
timeline is empty.
+        createTable(dstTable, s"${tmp.getCanonicalPath}/$dstTable")
+
+        val result = spark.sql(
+          s"""call sync_validate(src_table => '$srcTable', dst_table => 
'$dstTable',
+             | mode => 'noop', hive_server_url => 'jdbc:hive2://unused', 
hive_pass => 'x')"""
+            .stripMargin).collect()
+
+        assertResult(1)(result.length)
+        // The procedure prints the unqualified table name, while 
generateTableName is db-qualified.
+        val srcName = srcTable.stripPrefix("default.")
+        val dstName = dstTable.stripPrefix("default.")
+        // An empty destination timeline falls back to the sentinel latest 
commit "0", the realistic
+        // "target not yet synced" shape. The sentinel is never greater than 
the source's latest, so
+        // the else branch runs, and findInstantsAfter("0") makes the whole 
source timeline the
+        // catch-up set: both inserts are counted.
+        assertResult(s"Count difference now is count($srcName) - 
count($dstName) == 0. Catach up count is 2")(
+          result.head.getString(0))
+      }
+    }
+  }
+
+  test("Test Call sync_validate when the target table is mor (known 
limitation)") {
+    withTempDir { tmp =>
+      val srcTable = generateTableName
+      val dstTable = generateTableName
+      createTable(srcTable, s"${tmp.getCanonicalPath}/$srcTable")
+      spark.sql(s"insert into $srcTable select 1, 'a1', 10, 1000")
+
+      // Writes to a mor table land as deltacommits, so both of these are in 
the catch-up range.
+      createTable(dstTable, s"${tmp.getCanonicalPath}/$dstTable", "mor")
+      spark.sql(s"insert into $dstTable select 1, 'a1', 10, 1000")
+      spark.sql(s"insert into $dstTable select 2, 'a2', 20, 2000")
+
+      // Known limitation: the catch-up counting hardcodes the commit action 
when it rebuilds the
+      // instants, so it cannot resolve deltacommit instants and the procedure 
fails on any mor
+      // target with catch-up commits. See #19635. A fix flips this test to 
assert a count.
+      val e = intercept[HoodieIOException] {
+        spark.sql(
+          s"""call sync_validate(src_table => '$srcTable', dst_table => 
'$dstTable',
+             | mode => 'noop', hive_server_url => 'jdbc:hive2://unused', 
hive_pass => 'x')"""
+            .stripMargin).collect()
+      }
+      // The instant timestamp varies per run, but the synthesized commit 
action is the whole bug.
+      assert(e.getMessage.startsWith("Cannot find the instant["), e.getMessage)
+      assert(e.getMessage.endsWith("__commit__COMPLETED]"), e.getMessage)
+    }
+  }
+
+  test("Test Call sync_validate record-count modes fail fast on an unreachable 
HiveServer2") {
+    withTempDir { tmp =>
+      val tableName = generateTableName
+      createTable(tableName, s"${tmp.getCanonicalPath}/$tableName")
+      spark.sql(s"insert into $tableName select 1, 'a1', 10, 1000")
+
+      // The mode dispatch counts records before the timelines are compared, 
so a single table as
+      // both source and target is enough here: neither call survives the 
record counting.
+      // Nothing reaches the network either: the url carries no port, so the 
authority resolves to
+      // unused:-1 and the driver's transport setup rejects that port before 
any connect.
+
+      // mode = 'complete' routes to the countRecords overload that declares 
its connection as
+      // `var conn: Connection = null` and closes it in an unguarded `finally 
{ conn.close() }`.
+      // When DriverManager.getConnection throws, conn is still null, so the 
finally block raises a
+      // NullPointerException that replaces the real SQLException. This pins 
that masking bug, see
+      // #19635. A fix flips this test to assert an SQLException is present in 
the chain.
+      val completeFailure = intercept[Throwable] {
+        spark.sql(
+          s"""call sync_validate(src_table => '$tableName', dst_table => 
'$tableName',
+             | mode => 'complete', hive_server_url => 'jdbc:hive2://unused', 
hive_pass => 'x')"""
+            .stripMargin).collect()
+      }
+      // Assert over the cause chain, never the top-level type: on Spark 3.4+ 
QueryExecution wraps a
+      // NullPointerException thrown by an eagerly executed command into a 
SparkException
+      // [INTERNAL_ERROR]. Message text is off limits too, the NPE message is 
null on JDK 11 and the
+      // Hive URI wording moves with hive.version.
+      
assert(causeChain(completeFailure).exists(_.isInstanceOf[NullPointerException]),
+        chainTypes(completeFailure))
+      assert(!causeChain(completeFailure).exists(_.isInstanceOf[SQLException]),
+        chainTypes(completeFailure))
+
+      // mode = 'latestPartitions' routes to the sibling overload, which 
obtains the connection
+      // before entering its try and closes it under an `if (conn != null)` 
guard, so the connection
+      // failure surfaces as the SQLException it is instead of being masked.
+      val latestPartitionsFailure = intercept[Throwable] {
+        spark.sql(
+          s"""call sync_validate(src_table => '$tableName', dst_table => 
'$tableName',
+             | mode => 'latestPartitions', hive_server_url => 
'jdbc:hive2://unused', hive_pass => 'x')"""
+            .stripMargin).collect()
+      }
+      
assert(causeChain(latestPartitionsFailure).exists(_.isInstanceOf[SQLException]),
+        chainTypes(latestPartitionsFailure))
+    }
+  }
+}

Reply via email to