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

andygrove pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git


The following commit(s) were added to refs/heads/main by this push:
     new 07307b5c53 perf: cache Iceberg reflection lookups on the planning path 
(#5222)
07307b5c53 is described below

commit 07307b5c531280ad79ba8dd52feebf3f2fe0f224
Author: Andy Grove <[email protected]>
AuthorDate: Tue Aug 4 15:44:36 2026 -0600

    perf: cache Iceberg reflection lookups on the planning path (#5222)
    
    * perf: cache Iceberg reflection lookups on the planning path
    
    `Class.getMethod` linearly scans a class's public methods and returns a
    fresh `Method` copy per call, and Comet's Iceberg paths resolve the same
    handful of accessors once per file scan task, per partition field and per
    delete file. `extractFileLocation` additionally probes for `location()` on
    every file, so on Iceberg versions that only have `path()` each call built
    a `NoSuchMethodException`.
    
    Resolve lookups through a cache in `IcebergReflection`, keyed on the class
    via `ClassValue` so entries die with the class rather than pinning a
    classloader. Misses are cached too. Memoize the per-task field-id mapping
    and hoist the loop-invariant schema comparison out of the serialization
    loop.
    
    * refactor: simplify the Iceberg reflection cache
    
    Fold `setAccessible` into `findMethod` so accessibility is a property of the
    resolved method rather than of the call site, dropping the
    `findAccessibleMethod` / `getAccessibleMethod` pair and its cache-key
    namespace. Share the declared-method resolution between `getDeclaredMethod`
    and `findMethodInHierarchy`, and read the cache before `computeIfAbsent` so 
a
    hit does not allocate a mapping function.
    
    Drop the now-unused single-argument `getFileFormat`, and give
    `getEqualityFieldIds` the same class-taking shape so its callers stop
    reloading `DeleteFile` per delete file.
    
    In the serde, take the `PartitionSpecParser.toJson` accessor as a plain
    `Option[Method]` instead of a by-name parameter backed by a `lazy val`, and
    reuse the scan-schema field-id mapping the metadata already carries.
    
    * test: assert access suppression against a real package-private Iceberg 
file class
    
    The previous test resolved `transform()` on a suite-local class. scalac 
emits
    nested classes as public, so the invoke succeeded whether or not
    `makeAccessible` had run and only the `isAccessible` flag was really 
checked.
    
    Build a real `DataFile` instead: its concrete class `GenericDataFile` and 
the
    `BaseFile` that declares its accessors are both package-private in every 
Iceberg
    version Comet builds against, so invoking `path()` from the suite's package
    throws `IllegalAccessException` unless the resolved method had access checks
    suppressed. Modifier assertions guard against the test going vacuous if 
those
    classes ever become public.
---
 .../apache/comet/iceberg/IcebergReflection.scala   | 236 ++++++++++++++-------
 .../org/apache/comet/rules/CometScanRule.scala     |  35 +--
 .../serde/operator/CometIcebergNativeScan.scala    | 160 ++++++++------
 .../comet/iceberg/IcebergReflectionSuite.scala     |  98 ++++++++-
 4 files changed, 361 insertions(+), 168 deletions(-)

diff --git 
a/spark/src/main/scala/org/apache/comet/iceberg/IcebergReflection.scala 
b/spark/src/main/scala/org/apache/comet/iceberg/IcebergReflection.scala
index f867d4bc9b..c90d697bf7 100644
--- a/spark/src/main/scala/org/apache/comet/iceberg/IcebergReflection.scala
+++ b/spark/src/main/scala/org/apache/comet/iceberg/IcebergReflection.scala
@@ -19,6 +19,9 @@
 
 package org.apache.comet.iceberg
 
+import java.lang.reflect.Method
+import java.util.concurrent.ConcurrentHashMap
+
 import org.apache.spark.internal.Logging
 import org.apache.spark.sql.SparkSession
 
@@ -139,24 +142,102 @@ object IcebergReflection extends Logging {
    */
   def loadClass(className: String): Class[_] = 
ClassLoaders.loadClass(className)
 
+  /**
+   * Methods resolved by [[findMethod]], [[getDeclaredMethod]] and 
[[findMethodInHierarchy]],
+   * keyed by the class the lookup started from and then by the lookup itself.
+   *
+   * `Class.getMethod` linearly scans the class's public methods and returns a 
fresh defensive
+   * copy of the `Method` on every call. Comet resolves the same handful of 
Iceberg accessors once
+   * per file scan task, and again per partition field and per delete file, so 
planning a scan
+   * over a table with many files does O(files) reflective lookups that all 
resolve to the same
+   * few methods, repeated for every AQE stage.
+   *
+   * Misses are cached too, which matters most for [[extractFileLocation]]: it 
probes for
+   * `location()` on every file, and Iceberg versions that only have `path()` 
would otherwise
+   * construct a `NoSuchMethodException`, stack trace and all, per file.
+   *
+   * A `ClassValue` keys the cache on the class object itself, so entries are 
reclaimed with the
+   * class and a cached method never pins a classloader that Spark has 
discarded.
+   */
+  private val methodCache: ClassValue[ConcurrentHashMap[String, 
Option[Method]]] =
+    new ClassValue[ConcurrentHashMap[String, Option[Method]]] {
+      override protected def computeValue(
+          clazz: Class[_]): ConcurrentHashMap[String, Option[Method]] =
+        new ConcurrentHashMap[String, Option[Method]]()
+    }
+
+  private def cachedLookup(clazz: Class[_], key: String)(
+      resolve: => Option[Method]): Option[Method] = {
+    val perClass = methodCache.get(clazz)
+    // Read first: computeIfAbsent allocates the mapping function and can lock 
the bin even for a
+    // hit, and these lookups are almost always hits.
+    val cached = perClass.get(key)
+    if (cached != null) cached else perClass.computeIfAbsent(key, _ => resolve)
+  }
+
+  private def lookupKey(methodName: String, paramTypes: Seq[Class[_]]): String 
=
+    if (paramTypes.isEmpty) methodName
+    else paramTypes.map(_.getName).mkString(methodName + "(", ",", ")")
+
+  private def missing(clazz: Class[_], methodName: String, paramTypes: 
Seq[Class[_]]): Nothing =
+    throw new NoSuchMethodException(s"${clazz.getName}.${lookupKey(methodName, 
paramTypes)}")
+
+  /**
+   * Suppresses access checks so the method can be invoked when its declaring 
class is
+   * package-private, as Iceberg's concrete file/task/term implementations 
are. Runs once, when
+   * the method is first resolved. A JVM that refuses (a class in a module 
that is exported but
+   * not open) leaves the method usable for the public-class case, so the 
refusal is not fatal
+   * here.
+   */
+  private def makeAccessible(method: Method): Method = {
+    try method.setAccessible(true)
+    catch { case _: RuntimeException => }
+    method
+  }
+
+  private def declaredMethod(clazz: Class[_], methodName: String): 
Option[Method] =
+    try Some(makeAccessible(clazz.getDeclaredMethod(methodName)))
+    catch { case _: NoSuchMethodException => None }
+
+  /**
+   * Cached `Class.getMethod`, returning None instead of throwing when the 
method is absent. The
+   * resolved method has access checks suppressed (see [[makeAccessible]]).
+   */
+  def findMethod(clazz: Class[_], methodName: String, paramTypes: Class[_]*): 
Option[Method] =
+    cachedLookup(clazz, lookupKey(methodName, paramTypes)) {
+      try Some(makeAccessible(clazz.getMethod(methodName, paramTypes: _*)))
+      catch { case _: NoSuchMethodException => None }
+    }
+
+  /**
+   * Cached `Class.getMethod`, throwing `NoSuchMethodException` when the 
method is absent, like
+   * the JDK call it replaces.
+   */
+  def getMethod(clazz: Class[_], methodName: String, paramTypes: Class[_]*): 
Method =
+    findMethod(clazz, methodName, paramTypes: _*).getOrElse(
+      missing(clazz, methodName, paramTypes))
+
+  /**
+   * Cached `Class.getDeclaredMethod` with access checks suppressed, throwing
+   * `NoSuchMethodException` when the method is absent, like the JDK call it 
replaces.
+   */
+  def getDeclaredMethod(clazz: Class[_], methodName: String): Method =
+    cachedLookup(clazz, "declared:" + methodName)(declaredMethod(clazz, 
methodName))
+      .getOrElse(missing(clazz, methodName, Nil))
+
   /**
    * Searches through class hierarchy to find a method (including protected 
methods).
    */
-  def findMethodInHierarchy(
-      clazz: Class[_],
-      methodName: String): Option[java.lang.reflect.Method] = {
-    var current: Class[_] = clazz
-    while (current != null) {
-      try {
-        val method = current.getDeclaredMethod(methodName)
-        method.setAccessible(true)
-        return Some(method)
-      } catch {
-        case _: NoSuchMethodException => current = current.getSuperclass
+  def findMethodInHierarchy(clazz: Class[_], methodName: String): 
Option[Method] =
+    cachedLookup(clazz, "hierarchy:" + methodName) {
+      var current: Class[_] = clazz
+      var found: Option[Method] = None
+      while (found.isEmpty && current != null) {
+        found = declaredMethod(current, methodName)
+        if (found.isEmpty) current = current.getSuperclass
       }
+      found
     }
-    None
-  }
 
   /**
    * True if `clazz` or any of its superclasses has a name in `names`. Walks 
the already-loaded
@@ -184,16 +265,13 @@ object IcebergReflection extends Logging {
    */
   def extractFileLocation(contentFileClass: Class[_], file: Any): 
Option[String] = {
     try {
-      val locationMethod = contentFileClass.getMethod("location")
-      Some(locationMethod.invoke(file).asInstanceOf[String])
+      findMethod(contentFileClass, "location") match {
+        case Some(locationMethod) => 
Some(locationMethod.invoke(file).asInstanceOf[String])
+        case None =>
+          findMethod(contentFileClass, "path")
+            .map(_.invoke(file).asInstanceOf[CharSequence].toString)
+      }
     } catch {
-      case _: NoSuchMethodException =>
-        try {
-          val pathMethod = contentFileClass.getMethod("path")
-          Some(pathMethod.invoke(file).asInstanceOf[CharSequence].toString)
-        } catch {
-          case _: Exception => None
-        }
       case _: Exception => None
     }
   }
@@ -210,16 +288,16 @@ object IcebergReflection extends Logging {
     }
   }
 
-  /** The file format of a ContentFile (data or delete file), e.g. "PARQUET", 
"AVRO", "ORC". */
-  def getFileFormat(file: Any): Option[String] = {
+  /**
+   * The file format of a ContentFile (data or delete file), e.g. "PARQUET", 
"AVRO", "ORC".
+   *
+   * `contentFileClass` is the public ContentFile interface, which callers 
already hold: Iceberg's
+   * concrete file impls are package-private, so `format()` resolved on the 
concrete class throws
+   * IllegalAccessException when invoked.
+   */
+  def getFileFormat(contentFileClass: Class[_], file: Any): Option[String] = {
     try {
-      // Resolve format() on the public ContentFile interface. Iceberg's 
concrete file impls are
-      // package-private, so a method resolved on the concrete class throws 
IllegalAccessException
-      // when invoked.
-      // TODO callers in a loop (e.g. validateIcebergFileScanTasks) already 
hold a cached
-      // contentFileClass; add an overload that takes it to avoid reloading 
per file.
-      val contentFileClass = loadClass(ClassNames.CONTENT_FILE)
-      Some(contentFileClass.getMethod("format").invoke(file).toString)
+      findMethod(contentFileClass, "format").map(_.invoke(file).toString)
     } catch {
       case _: Exception => None
     }
@@ -279,7 +357,7 @@ object IcebergReflection extends Logging {
           } else {
             // All task groups in a stage share the same concrete class, so 
the per-group
             // `tasks()` lookup can be cached once instead of done N times.
-            val groupTasksMethod = groups.get(0).getClass.getMethod("tasks")
+            val groupTasksMethod = getMethod(groups.get(0).getClass, "tasks")
             val flat = new java.util.ArrayList[AnyRef]()
             groups.forEach { group =>
               val groupTasks =
@@ -335,19 +413,17 @@ object IcebergReflection extends Logging {
    */
   def getFormatVersion(table: Any): Option[Int] = {
     try {
-      val formatVersionMethod = table.getClass.getMethod("formatVersion")
+      val formatVersionMethod = getMethod(table.getClass, "formatVersion")
       Some(formatVersionMethod.invoke(table).asInstanceOf[Int])
     } catch {
       case _: NoSuchMethodException =>
         try {
           // If not directly available, access via operations/metadata
-          val opsMethod = table.getClass.getDeclaredMethod("operations")
-          opsMethod.setAccessible(true)
-          val ops = opsMethod.invoke(table)
+          val ops = getDeclaredMethod(table.getClass, 
"operations").invoke(table)
           findMethodInHierarchy(ops.getClass, "current")
             .flatMap { currentMethod =>
               val metadata = currentMethod.invoke(ops)
-              val formatVersionMethod = 
metadata.getClass.getMethod("formatVersion")
+              val formatVersionMethod = getMethod(metadata.getClass, 
"formatVersion")
               Some(formatVersionMethod.invoke(metadata).asInstanceOf[Int])
             }
             .orElse {
@@ -372,7 +448,7 @@ object IcebergReflection extends Logging {
    */
   def getFileIO(table: Any): Option[Any] = {
     try {
-      val ioMethod = table.getClass.getMethod("io")
+      val ioMethod = getMethod(table.getClass, "io")
       Some(ioMethod.invoke(table))
     } catch {
       case e: Exception =>
@@ -412,7 +488,7 @@ object IcebergReflection extends Logging {
    */
   def getSchema(table: Any): Option[Any] = {
     try {
-      val schemaMethod = table.getClass.getMethod("schema")
+      val schemaMethod = getMethod(table.getClass, "schema")
       Some(schemaMethod.invoke(table))
     } catch {
       case e: Exception =>
@@ -429,8 +505,7 @@ object IcebergReflection extends Logging {
   def getAllSchemas(table: Any): Seq[Any] = {
     import scala.jdk.CollectionConverters._
     try {
-      table.getClass
-        .getMethod("schemas")
+      getMethod(table.getClass, "schemas")
         .invoke(table)
         .asInstanceOf[java.util.Map[_, _]]
         .values()
@@ -446,7 +521,7 @@ object IcebergReflection extends Logging {
   /** Returns the `Types.NestedField` for `fieldId` in `schema`, or None. */
   def findFieldObject(schema: Any, fieldId: Int): Option[Any] = {
     try {
-      val findFieldMethod = schema.getClass.getMethod("findField", 
classOf[Int])
+      val findFieldMethod = getMethod(schema.getClass, "findField", 
classOf[Int])
       Option(findFieldMethod.invoke(schema, fieldId.asInstanceOf[AnyRef]))
     } catch {
       case _: Exception => None
@@ -483,8 +558,7 @@ object IcebergReflection extends Logging {
             s"Cannot resolve equality-delete field id $id in table schema 
history"))
       }
       val existing =
-        baseSchema.getClass
-          .getMethod("columns")
+        getMethod(baseSchema.getClass, "columns")
           .invoke(baseSchema)
           .asInstanceOf[java.util.List[_]]
       val newColumns = new java.util.ArrayList[Any](existing)
@@ -501,7 +575,7 @@ object IcebergReflection extends Logging {
    */
   def getPartitionSpec(table: Any): Option[Any] = {
     try {
-      val specMethod = table.getClass.getMethod("spec")
+      val specMethod = getMethod(table.getClass, "spec")
       Some(specMethod.invoke(table))
     } catch {
       case e: Exception =>
@@ -531,8 +605,7 @@ object IcebergReflection extends Logging {
     try {
       val tableClass = loadClass(ClassNames.TABLE)
       val partitioningClass = loadClass(ClassNames.PARTITIONING)
-      partitioningClass
-        .getMethod("partitionType", tableClass)
+      getMethod(partitioningClass, "partitionType", tableClass)
         .invoke(null, table.asInstanceOf[AnyRef])
       None
     } catch {
@@ -555,9 +628,7 @@ object IcebergReflection extends Logging {
    */
   def getTableMetadata(table: Any): Option[Any] = {
     try {
-      val operationsMethod = table.getClass.getDeclaredMethod("operations")
-      operationsMethod.setAccessible(true)
-      val operations = operationsMethod.invoke(table)
+      val operations = getDeclaredMethod(table.getClass, 
"operations").invoke(table)
 
       findMethodInHierarchy(operations.getClass, 
"current").map(_.invoke(operations)).orElse {
         logError(
@@ -589,7 +660,7 @@ object IcebergReflection extends Logging {
   def getMetadataLocation(table: Any): Option[String] = {
     getTableMetadata(table).flatMap { metadata =>
       try {
-        val metadataFileLocationMethod = 
metadata.getClass.getMethod("metadataFileLocation")
+        val metadataFileLocationMethod = getMethod(metadata.getClass, 
"metadataFileLocation")
         Some(metadataFileLocationMethod.invoke(metadata).asInstanceOf[String])
       } catch {
         case e: Exception =>
@@ -611,7 +682,7 @@ object IcebergReflection extends Logging {
   def getTableProperties(table: Any): Option[java.util.Map[String, String]] = {
     getTableMetadata(table).flatMap { metadata =>
       try {
-        val propertiesMethod = metadata.getClass.getMethod("properties")
+        val propertiesMethod = getMethod(metadata.getClass, "properties")
         
Some(propertiesMethod.invoke(metadata).asInstanceOf[java.util.Map[String, 
String]])
       } catch {
         case e: Exception =>
@@ -634,7 +705,7 @@ object IcebergReflection extends Logging {
    *   if reflection fails (callers must handle appropriately based on context)
    */
   def getDeleteFilesFromTask(task: Any, fileScanTaskClass: Class[_]): 
java.util.List[_] = {
-    val deletesMethod = fileScanTaskClass.getMethod("deletes")
+    val deletesMethod = getMethod(fileScanTaskClass, "deletes")
     val deletes = deletesMethod.invoke(task).asInstanceOf[java.util.List[_]]
     if (deletes == null) new java.util.ArrayList[Any]() else deletes
   }
@@ -642,16 +713,19 @@ object IcebergReflection extends Logging {
   /**
    * Gets equality field IDs from a delete file.
    *
+   * @param deleteFileClass
+   *   The DeleteFile interface, which callers in a loop already hold
    * @param deleteFile
    *   An Iceberg DeleteFile object
    * @return
    *   List of field IDs used in equality deletes, or empty list for position 
deletes
    */
-  def getEqualityFieldIds(deleteFile: Any): java.util.List[_] = {
+  def getEqualityFieldIds(deleteFileClass: Class[_], deleteFile: Any): 
java.util.List[_] = {
     try {
-      val deleteFileClass = loadClass(ClassNames.DELETE_FILE)
-      val equalityFieldIdsMethod = 
deleteFileClass.getMethod("equalityFieldIds")
-      val ids = 
equalityFieldIdsMethod.invoke(deleteFile).asInstanceOf[java.util.List[_]]
+      val ids =
+        getMethod(deleteFileClass, "equalityFieldIds")
+          .invoke(deleteFile)
+          .asInstanceOf[java.util.List[_]]
       if (ids == null) new java.util.ArrayList[Any]() else ids
     } catch {
       case _: Exception =>
@@ -672,11 +746,11 @@ object IcebergReflection extends Logging {
    */
   def getFieldInfo(schema: Any, fieldId: Int): Option[(String, String)] = {
     try {
-      val findFieldMethod = schema.getClass.getMethod("findField", 
classOf[Int])
+      val findFieldMethod = getMethod(schema.getClass, "findField", 
classOf[Int])
       val field = findFieldMethod.invoke(schema, fieldId.asInstanceOf[AnyRef])
       if (field != null) {
-        val nameMethod = field.getClass.getMethod("name")
-        val typeMethod = field.getClass.getMethod("type")
+        val nameMethod = getMethod(field.getClass, "name")
+        val typeMethod = getMethod(field.getClass, "type")
         val fieldName = nameMethod.invoke(field).toString
         val fieldType = typeMethod.invoke(field).toString
         Some((fieldName, fieldType))
@@ -734,15 +808,15 @@ object IcebergReflection extends Logging {
   def buildFieldIdMapping(schema: Any): Map[String, Int] = {
     import scala.jdk.CollectionConverters._
     try {
-      val columnsMethod = schema.getClass.getMethod("columns")
+      val columnsMethod = getMethod(schema.getClass, "columns")
       val columns = 
columnsMethod.invoke(schema).asInstanceOf[java.util.List[_]]
 
       columns.asScala.flatMap { column =>
         try {
-          val nameMethod = column.getClass.getMethod("name")
+          val nameMethod = getMethod(column.getClass, "name")
           val name = nameMethod.invoke(column).asInstanceOf[String]
 
-          val fieldIdMethod = column.getClass.getMethod("fieldId")
+          val fieldIdMethod = getMethod(column.getClass, "fieldId")
           val fieldId = fieldIdMethod.invoke(column).asInstanceOf[Int]
 
           Some(name -> fieldId)
@@ -773,13 +847,12 @@ object IcebergReflection extends Logging {
   def pageIndexUnsupportedColumns(schema: Any): Set[String] = {
     import scala.jdk.CollectionConverters._
     try {
-      val columns = schema.getClass
-        .getMethod("columns")
+      val columns = getMethod(schema.getClass, "columns")
         .invoke(schema)
         .asInstanceOf[java.util.List[_]]
       columns.asScala.flatMap { column =>
-        val name = 
column.getClass.getMethod("name").invoke(column).asInstanceOf[String]
-        val typeStr = column.getClass.getMethod("type").invoke(column).toString
+        val name = getMethod(column.getClass, 
"name").invoke(column).asInstanceOf[String]
+        val typeStr = getMethod(column.getClass, 
"type").invoke(column).toString
         if (typeStr.startsWith("decimal(") || typeStr == "uuid" || 
typeStr.startsWith("fixed[") ||
           typeStr == "binary") {
           Some(name)
@@ -812,12 +885,12 @@ object IcebergReflection extends Logging {
   def validatePartitionTypes(partitionSpec: Any, schema: Any): List[(String, 
String, String)] = {
     import scala.jdk.CollectionConverters._
 
-    val fieldsMethod = partitionSpec.getClass.getMethod("fields")
+    val fieldsMethod = getMethod(partitionSpec.getClass, "fields")
     val fields = 
fieldsMethod.invoke(partitionSpec).asInstanceOf[java.util.List[_]]
 
     val partitionFieldClass = loadClass(ClassNames.PARTITION_FIELD)
-    val sourceIdMethod = partitionFieldClass.getMethod("sourceId")
-    val findFieldMethod = schema.getClass.getMethod("findField", classOf[Int])
+    val sourceIdMethod = getMethod(partitionFieldClass, "sourceId")
+    val findFieldMethod = getMethod(schema.getClass, "findField", classOf[Int])
 
     val unsupportedTypes = scala.collection.mutable.ListBuffer[(String, 
String, String)]()
 
@@ -826,10 +899,10 @@ object IcebergReflection extends Logging {
       val column = findFieldMethod.invoke(schema, 
sourceId.asInstanceOf[Object])
 
       if (column != null) {
-        val nameMethod = column.getClass.getMethod("name")
+        val nameMethod = getMethod(column.getClass, "name")
         val fieldName = nameMethod.invoke(column).asInstanceOf[String]
 
-        val typeMethod = column.getClass.getMethod("type")
+        val typeMethod = getMethod(column.getClass, "type")
         val icebergType = typeMethod.invoke(column)
         val typeStr = icebergType.toString
 
@@ -866,22 +939,21 @@ object IcebergReflection extends Logging {
   def columnsWithInitialDefault(schema: Any): List[String] = {
     import scala.jdk.CollectionConverters._
     val columns =
-      
schema.getClass.getMethod("columns").invoke(schema).asInstanceOf[java.util.List[_]]
+      getMethod(schema.getClass, 
"columns").invoke(schema).asInstanceOf[java.util.List[_]]
     columns.asScala.flatMap(walkFieldForDefault).toList
   }
 
   private def walkFieldForDefault(field: Any): List[String] = {
     import scala.jdk.CollectionConverters._
-    val name = 
field.getClass.getMethod("name").invoke(field).asInstanceOf[String]
+    val name = getMethod(field.getClass, 
"name").invoke(field).asInstanceOf[String]
     val here =
-      if (field.getClass.getMethod("initialDefault").invoke(field) != null) 
List(name) else Nil
-    val fieldType = field.getClass.getMethod("type").invoke(field)
+      if (getMethod(field.getClass, "initialDefault").invoke(field) != null) 
List(name) else Nil
+    val fieldType = getMethod(field.getClass, "type").invoke(field)
     val nested =
-      if 
(fieldType.getClass.getMethod("isNestedType").invoke(fieldType).asInstanceOf[Boolean])
 {
-        val nestedType = 
fieldType.getClass.getMethod("asNestedType").invoke(fieldType)
+      if (getMethod(fieldType.getClass, 
"isNestedType").invoke(fieldType).asInstanceOf[Boolean]) {
+        val nestedType = getMethod(fieldType.getClass, 
"asNestedType").invoke(fieldType)
         val fields =
-          nestedType.getClass
-            .getMethod("fields")
+          getMethod(nestedType.getClass, "fields")
             .invoke(nestedType)
             .asInstanceOf[java.util.List[_]]
         fields.asScala.flatMap(walkFieldForDefault).toList
@@ -901,7 +973,7 @@ object IcebergReflection extends Logging {
   def toSparkSchema(schema: Any): org.apache.spark.sql.types.StructType = {
     val sparkSchemaUtil = loadClass(ClassNames.SPARK_SCHEMA_UTIL)
     val schemaClass = loadClass(ClassNames.SCHEMA)
-    val convert = sparkSchemaUtil.getMethod("convert", schemaClass)
+    val convert = getMethod(sparkSchemaUtil, "convert", schemaClass)
     convert
       .invoke(null, schema.asInstanceOf[AnyRef])
       .asInstanceOf[org.apache.spark.sql.types.StructType]
@@ -1048,7 +1120,7 @@ object CometIcebergNativeScanMetadata extends Logging {
 
   private def invokeTableName(table: Any): Option[String] = {
     try {
-      table.getClass.getMethod("name").invoke(table) match {
+      IcebergReflection.getMethod(table.getClass, "name").invoke(table) match {
         case s: String => Some(s)
         case other if other != null => Some(other.toString)
         case null => None
diff --git a/spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala 
b/spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala
index ca917335da..a57475d2f5 100644
--- a/spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala
+++ b/spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala
@@ -419,7 +419,7 @@ case class CometScanRule(session: SparkSession)
                 tableOpt
                   .flatMap { table =>
                     try {
-                      val locationMethod = table.getClass.getMethod("location")
+                      val locationMethod = 
IcebergReflection.getMethod(table.getClass, "location")
                       val tableLocation = 
locationMethod.invoke(table).asInstanceOf[String]
                       Some(tableLocation)
                     } catch {
@@ -719,10 +719,14 @@ case class CometScanRule(session: SparkSession)
           try {
             if (!taskValidation.deleteFiles.isEmpty) {
               val historicSchemas = 
IcebergReflection.getAllSchemas(metadata.table)
+              val contentFileClass =
+                
IcebergReflection.loadClass(IcebergReflection.ClassNames.CONTENT_FILE)
+              val deleteFileClass =
+                
IcebergReflection.loadClass(IcebergReflection.ClassNames.DELETE_FILE)
               taskValidation.deleteFiles.asScala.foreach { deleteFile =>
                 // iceberg-rust only reads Parquet delete files. Avro/ORC 
positional or
                 // equality deletes must be applied by Spark.
-                IcebergReflection.getFileFormat(deleteFile) match {
+                IcebergReflection.getFileFormat(contentFileClass, deleteFile) 
match {
                   case Some(fmt) if 
fmt.equalsIgnoreCase(IcebergReflection.FileFormats.PARQUET) =>
                   case Some(fmt) =>
                     hasUnsupportedDeletes = true
@@ -736,7 +740,8 @@ case class CometScanRule(session: SparkSession)
                     fallbackReasons += "Could not determine Iceberg delete 
file format"
                 }
 
-                val equalityFieldIds = 
IcebergReflection.getEqualityFieldIds(deleteFile)
+                val equalityFieldIds =
+                  IcebergReflection.getEqualityFieldIds(deleteFileClass, 
deleteFile)
 
                 if (!equalityFieldIds.isEmpty) {
                   equalityFieldIds.asScala.foreach { fieldId =>
@@ -983,13 +988,13 @@ object CometScanRule extends Logging {
     val unboundPredicateClass =
       
IcebergReflection.loadClass(IcebergReflection.ClassNames.UNBOUND_PREDICATE)
 
-    // Cache all method lookups outside the loop
-    val fileMethod = contentScanTaskClass.getMethod("file")
-    val formatMethod = contentFileClass.getMethod("format")
-    val pathMethod = contentFileClass.getMethod("path")
-    val residualMethod = contentScanTaskClass.getMethod("residual")
-    val deletesMethod = fileScanTaskClass.getMethod("deletes")
-    val termMethod = unboundPredicateClass.getMethod("term")
+    // Resolve all method lookups outside the loop
+    val fileMethod = IcebergReflection.getMethod(contentScanTaskClass, "file")
+    val formatMethod = IcebergReflection.getMethod(contentFileClass, "format")
+    val pathMethod = IcebergReflection.getMethod(contentFileClass, "path")
+    val residualMethod = IcebergReflection.getMethod(contentScanTaskClass, 
"residual")
+    val deletesMethod = IcebergReflection.getMethod(fileScanTaskClass, 
"deletes")
+    val termMethod = IcebergReflection.getMethod(unboundPredicateClass, "term")
 
     val supportedSchemes =
       Set("file", "s3", "s3a", "gs", "gcs", "oss", "abfss", "abfs", "wasbs", 
"wasb")
@@ -1026,16 +1031,12 @@ object CometScanRule extends Logging {
           val residual = residualMethod.invoke(task)
           if (unboundPredicateClass.isInstance(residual)) {
             val term = termMethod.invoke(residual)
-            try {
-              val transformMethod = term.getClass.getMethod("transform")
-              transformMethod.setAccessible(true)
-              val transform = transformMethod.invoke(term)
-              val transformStr = transform.toString
+            // A term with no transform() is a simple reference, which is fine.
+            IcebergReflection.findMethod(term.getClass, "transform").foreach { 
transformMethod =>
+              val transformStr = transformMethod.invoke(term).toString
               if (transformStr != IcebergReflection.Transforms.IDENTITY) {
                 nonIdentityTransform = Some(transformStr)
               }
-            } catch {
-              case _: NoSuchMethodException => // No transform = simple 
reference, OK
             }
           }
         } catch {
diff --git 
a/spark/src/main/scala/org/apache/comet/serde/operator/CometIcebergNativeScan.scala
 
b/spark/src/main/scala/org/apache/comet/serde/operator/CometIcebergNativeScan.scala
index bb81fd69ca..158d4a5995 100644
--- 
a/spark/src/main/scala/org/apache/comet/serde/operator/CometIcebergNativeScan.scala
+++ 
b/spark/src/main/scala/org/apache/comet/serde/operator/CometIcebergNativeScan.scala
@@ -19,6 +19,7 @@
 
 package org.apache.comet.serde.operator
 
+import java.lang.reflect.Method
 import java.math.BigDecimal
 import java.nio.ByteBuffer
 import java.nio.charset.StandardCharsets.UTF_8
@@ -271,11 +272,11 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
   private def extractDeleteFilesList(
       task: Any,
       contentFileClass: Class[_],
-      fileScanTaskClass: Class[_]): Seq[OperatorOuterClass.IcebergDeleteFile] 
= {
+      fileScanTaskClass: Class[_],
+      deleteFileClass: Class[_]): Seq[OperatorOuterClass.IcebergDeleteFile] = {
     try {
-      val deleteFileClass = 
IcebergReflection.loadClass(IcebergReflection.ClassNames.DELETE_FILE)
       // keyMetadata() is declared on ContentFile; present across all 
supported Iceberg versions.
-      val keyMetadataMethod = contentFileClass.getMethod("keyMetadata")
+      val keyMetadataMethod = IcebergReflection.getMethod(contentFileClass, 
"keyMetadata")
 
       val deletes = IcebergReflection.getDeleteFilesFromTask(task, 
fileScanTaskClass)
 
@@ -292,7 +293,7 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
 
         val contentType =
           try {
-            val contentMethod = deleteFileClass.getMethod("content")
+            val contentMethod = IcebergReflection.getMethod(deleteFileClass, 
"content")
             val content = contentMethod.invoke(deleteFile)
             content.toString match {
               case IcebergReflection.ContentTypes.POSITION_DELETES =>
@@ -309,7 +310,7 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
 
         val specId =
           try {
-            val specIdMethod = deleteFileClass.getMethod("specId")
+            val specIdMethod = IcebergReflection.getMethod(deleteFileClass, 
"specId")
             specIdMethod.invoke(deleteFile).asInstanceOf[Int]
           } catch {
             case _: Exception => 0
@@ -318,7 +319,7 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
 
         try {
           val equalityIdsMethod =
-            deleteFileClass.getMethod("equalityFieldIds")
+            IcebergReflection.getMethod(deleteFileClass, "equalityFieldIds")
           val equalityIds = equalityIdsMethod
             .invoke(deleteFile)
             .asInstanceOf[java.util.List[Integer]]
@@ -355,29 +356,28 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
       task: Any,
       contentScanTaskClass: Class[_],
       fileScanTaskClass: Class[_],
+      partitionSpecToJson: Option[Method],
       taskBuilder: OperatorOuterClass.IcebergFileScanTask.Builder,
       commonBuilder: OperatorOuterClass.IcebergScanCommon.Builder,
       partitionSpecToPoolIndex: mutable.HashMap[String, Int],
       partitionDataToPoolIndex: mutable.HashMap[String, Int]): Unit = {
     try {
-      val specMethod = fileScanTaskClass.getMethod("spec")
+      val specMethod = IcebergReflection.getMethod(fileScanTaskClass, "spec")
       val spec = specMethod.invoke(task)
 
       if (spec != null) {
         // Get the partition type/schema from the spec. Needed regardless of 
whether this task
         // ends up value-less below.
-        val partitionTypeMethod = spec.getClass.getMethod("partitionType")
+        val partitionTypeMethod = IcebergReflection.getMethod(spec.getClass, 
"partitionType")
         val partitionType = partitionTypeMethod.invoke(spec)
-        val fieldsMethod = partitionType.getClass.getMethod("fields")
+        val fieldsMethod = IcebergReflection.getMethod(partitionType.getClass, 
"fields")
         val fields = fieldsMethod
           .invoke(partitionType)
           .asInstanceOf[java.util.List[_]]
 
         // Helper to get field type string (shared by both type and data 
serialization)
-        def getFieldType(field: Any): String = {
-          val typeMethod = field.getClass.getMethod("type")
-          typeMethod.invoke(field).toString
-        }
+        def getFieldType(field: Any): String =
+          IcebergReflection.getMethod(field.getClass, 
"type").invoke(field).toString
 
         // Filter out fields with unknown types (dropped partition fields).
         // Unknown type fields represent partition columns that have been 
dropped
@@ -392,13 +392,13 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
           if (fieldTypeStr == IcebergReflection.TypeNames.UNKNOWN) {
             None
           } else {
-            val fieldIdMethod = field.getClass.getMethod("fieldId")
+            val fieldIdMethod = IcebergReflection.getMethod(field.getClass, 
"fieldId")
             val fieldId = fieldIdMethod.invoke(field).asInstanceOf[Int]
 
-            val nameMethod = field.getClass.getMethod("name")
+            val nameMethod = IcebergReflection.getMethod(field.getClass, 
"name")
             val fieldName = nameMethod.invoke(field).asInstanceOf[String]
 
-            val isOptionalMethod = field.getClass.getMethod("isOptional")
+            val isOptionalMethod = IcebergReflection.getMethod(field.getClass, 
"isOptional")
             val isOptional =
               isOptionalMethod.invoke(field).asInstanceOf[Boolean]
             val required = !isOptional
@@ -425,12 +425,8 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
         // adds its type-pool entry in the same block to keep them aligned.
         def serializeRealSpec(): Unit = {
           try {
-            val partitionSpecParserClass =
-              
IcebergReflection.loadClass(IcebergReflection.ClassNames.PARTITION_SPEC_PARSER)
-            val toJsonMethod = partitionSpecParserClass.getMethod(
-              "toJson",
-              
IcebergReflection.loadClass(IcebergReflection.ClassNames.PARTITION_SPEC))
-            val partitionSpecJson = toJsonMethod
+            val partitionSpecJson = partitionSpecToJson
+              .getOrElse(throw new 
NoSuchMethodException("PartitionSpecParser.toJson"))
               .invoke(null, spec)
               .asInstanceOf[String]
 
@@ -457,7 +453,7 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
         }
 
         // Get partition data from the task (via file().partition())
-        val partitionMethod = contentScanTaskClass.getMethod("partition")
+        val partitionMethod = 
IcebergReflection.getMethod(contentScanTaskClass, "partition")
         val partitionData = partitionMethod.invoke(task)
 
         if (partitionData != null) {
@@ -480,12 +476,13 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
                 None
               } else {
                 // Use the partition type's field ID (same as in 
partition_type_json)
-                val fieldIdMethod = field.getClass.getMethod("fieldId")
+                val fieldIdMethod = 
IcebergReflection.getMethod(field.getClass, "fieldId")
                 val fieldId = fieldIdMethod.invoke(field).asInstanceOf[Int]
 
-                val getMethod =
-                  partitionData.getClass.getMethod("get", classOf[Int], 
classOf[Class[_]])
-                val value = getMethod.invoke(partitionData, 
Integer.valueOf(idx), classOf[Object])
+                val getValueMethod = IcebergReflection
+                  .getMethod(partitionData.getClass, "get", classOf[Int], 
classOf[Class[_]])
+                val value =
+                  getValueMethod.invoke(partitionData, Integer.valueOf(idx), 
classOf[Object])
 
                 Some(partitionValueToProto(fieldId, fieldTypeStr, value))
               }
@@ -506,7 +503,8 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
           // Only the value-carrying path serializes the real spec 
(serializeRealSpec), so the
           // value-less path never does the reflection/intern work just to 
overwrite it.
           if (partitionValues.isEmpty) {
-            val specId = 
spec.getClass.getMethod("specId").invoke(spec).asInstanceOf[Int]
+            val specId =
+              IcebergReflection.getMethod(spec.getClass, 
"specId").invoke(spec).asInstanceOf[Int]
             val emptySpecJson =
               compact(
                 render(("spec-id" -> specId) ~ ("fields" -> 
List.empty[org.json4s.JObject])))
@@ -630,10 +628,11 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
       val attributeMap = output.map(attr => attr.name -> attr).toMap
 
       if 
(exprClass.getName.endsWith(Constants.ExpressionTypes.UNBOUND_PREDICATE)) {
-        val operation = exprClass.getMethod("op").invoke(icebergExpr).toString
-        val term = exprClass.getMethod("term").invoke(icebergExpr)
-        val ref = term.getClass.getMethod("ref").invoke(term)
-        val columnName = 
ref.getClass.getMethod("name").invoke(ref).asInstanceOf[String]
+        val operation = IcebergReflection.getMethod(exprClass, 
"op").invoke(icebergExpr).toString
+        val term = IcebergReflection.getMethod(exprClass, 
"term").invoke(icebergExpr)
+        val ref = IcebergReflection.getMethod(term.getClass, 
"ref").invoke(term)
+        val columnName =
+          IcebergReflection.getMethod(ref.getClass, 
"name").invoke(ref).asInstanceOf[String]
 
         // Iceberg names a nested reference by its dotted path 
("struct.field"), which never matches
         // a top-level scan output attribute, so a residual on a nested field 
drops here. That miss
@@ -660,11 +659,11 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
         }
       } else if (exprClass.getName.endsWith(Constants.ExpressionTypes.AND)) {
         val left = icebergExprToProto(
-          exprClass.getMethod("left").invoke(icebergExpr),
+          IcebergReflection.getMethod(exprClass, "left").invoke(icebergExpr),
           output,
           pageIndexUnsupportedColumns)
         val right = icebergExprToProto(
-          exprClass.getMethod("right").invoke(icebergExpr),
+          IcebergReflection.getMethod(exprClass, "right").invoke(icebergExpr),
           output,
           pageIndexUnsupportedColumns)
         (left, right) match {
@@ -677,11 +676,11 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
         }
       } else if (exprClass.getName.endsWith(Constants.ExpressionTypes.OR)) {
         val left = icebergExprToProto(
-          exprClass.getMethod("left").invoke(icebergExpr),
+          IcebergReflection.getMethod(exprClass, "left").invoke(icebergExpr),
           output,
           pageIndexUnsupportedColumns)
         val right = icebergExprToProto(
-          exprClass.getMethod("right").invoke(icebergExpr),
+          IcebergReflection.getMethod(exprClass, "right").invoke(icebergExpr),
           output,
           pageIndexUnsupportedColumns)
         // Dropping a disjunct would strengthen the predicate and wrongly 
prune, so require both.
@@ -690,7 +689,7 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
           case _ => None
         }
       } else if (exprClass.getName.endsWith(Constants.ExpressionTypes.NOT)) {
-        val child = exprClass.getMethod("child").invoke(icebergExpr)
+        val child = IcebergReflection.getMethod(exprClass, 
"child").invoke(icebergExpr)
         icebergExprToProto(child, output, 
pageIndexUnsupportedColumns).map(notPredicate)
       } else {
         None
@@ -722,7 +721,7 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
       attribute: Attribute,
       op: OperatorOuterClass.IcebergPredicateOperator)
       : Option[OperatorOuterClass.IcebergPredicate] = {
-    val literal = exprClass.getMethod("literal").invoke(icebergExpr)
+    val literal = IcebergReflection.getMethod(exprClass, 
"literal").invoke(icebergExpr)
     predicateLiteralToProto(attribute.dataType, 
icebergLiteralValue(literal)).map { lit =>
       OperatorOuterClass.IcebergPredicate
         .newBuilder()
@@ -742,7 +741,10 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
       column: String,
       attribute: Attribute): Option[OperatorOuterClass.IcebergPredicate] = {
     val literals =
-      
exprClass.getMethod("literals").invoke(icebergExpr).asInstanceOf[java.util.List[_]]
+      IcebergReflection
+        .getMethod(exprClass, "literals")
+        .invoke(icebergExpr)
+        .asInstanceOf[java.util.List[_]]
     val protoLiterals =
       literals.asScala.map(l =>
         predicateLiteralToProto(attribute.dataType, icebergLiteralValue(l)))
@@ -784,7 +786,7 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
   /** Extracts the raw Java value from an Iceberg Literal via reflection. */
   private def icebergLiteralValue(icebergLiteral: Any): Any = {
     val literalClass = 
IcebergReflection.loadClass(IcebergReflection.ClassNames.LITERAL)
-    literalClass.getMethod("value").invoke(icebergLiteral)
+    IcebergReflection.getMethod(literalClass, "value").invoke(icebergLiteral)
   }
 
   /**
@@ -906,6 +908,20 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
     val deleteFilesToPoolIndex =
       mutable.HashMap[Seq[Int], Int]()
     val residualToPoolIndex = 
mutable.HashMap[OperatorOuterClass.IcebergPredicate, Int]()
+    // Field-id mappings are read out of an Iceberg schema by reflection, one 
lookup per column, so
+    // memoize them. Keyed like schemaToPoolIndex above: a task schema that 
Iceberg materializes
+    // fresh per task misses, but the table/scan schema shared by every task 
hits.
+    val fieldIdMappingCache = mutable.HashMap[AnyRef, Map[String, Int]]()
+    def fieldIdMapping(schema: AnyRef): Map[String, Int] =
+      fieldIdMappingCache.getOrElseUpdate(schema, 
IcebergReflection.buildFieldIdMapping(schema))
+    // Whether the scan schema references field ids the current table schema 
no longer has (a
+    // dropped column read through VERSION AS OF). Loop-invariant, and lazy so 
a scan whose tasks
+    // all carry deletes never walks the table schema at all.
+    lazy val hasHistoricalColumns = {
+      val tableSchemaFieldIds =
+        fieldIdMapping(metadata.tableSchema.asInstanceOf[AnyRef]).values.toSet
+      metadata.globalFieldIdMapping.values.exists(id => 
!tableSchemaFieldIds.contains(id))
+    }
     // Columns whose Iceberg type iceberg-rust cannot use for page-index 
pruning; residual
     // predicates over them are dropped (see icebergExprToProto). Computed 
once from the full table
     // schema so a filter column projected out of the scan output is still 
recognized.
@@ -944,19 +960,34 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
       IcebergReflection.loadClass(IcebergReflection.ClassNames.SCHEMA_PARSER)
     val schemaClass =
       IcebergReflection.loadClass(IcebergReflection.ClassNames.SCHEMA)
+    val deleteFileClass =
+      IcebergReflection.loadClass(IcebergReflection.ClassNames.DELETE_FILE)
+    // Optional rather than required: serializePartitionData reports an 
unresolvable
+    // PartitionSpecParser.toJson as a per-task warning that leaves the task 
without a partition
+    // spec, so it must not fail the whole scan here.
+    val partitionSpecToJson =
+      try {
+        Some(
+          IcebergReflection.getMethod(
+            
IcebergReflection.loadClass(IcebergReflection.ClassNames.PARTITION_SPEC_PARSER),
+            "toJson",
+            
IcebergReflection.loadClass(IcebergReflection.ClassNames.PARTITION_SPEC)))
+      } catch {
+        case _: Exception => None
+      }
 
-    // Cache method lookups (avoid repeated getMethod in loop)
-    val fileMethod = contentScanTaskClass.getMethod("file")
-    val startMethod = contentScanTaskClass.getMethod("start")
-    val lengthMethod = contentScanTaskClass.getMethod("length")
-    val residualMethod = contentScanTaskClass.getMethod("residual")
-    val fileSizeInBytesMethod = contentFileClass.getMethod("fileSizeInBytes")
+    // Accessors used by the per-task loop
+    val fileMethod = IcebergReflection.getMethod(contentScanTaskClass, "file")
+    val startMethod = IcebergReflection.getMethod(contentScanTaskClass, 
"start")
+    val lengthMethod = IcebergReflection.getMethod(contentScanTaskClass, 
"length")
+    val residualMethod = IcebergReflection.getMethod(contentScanTaskClass, 
"residual")
+    val fileSizeInBytesMethod = IcebergReflection.getMethod(contentFileClass, 
"fileSizeInBytes")
     // keyMetadata() is declared on ContentFile (present across all supported 
Iceberg versions).
     // For encrypted tables it returns the plaintext StandardKeyMetadata blob; 
null otherwise.
-    val keyMetadataMethod = contentFileClass.getMethod("keyMetadata")
-    val taskSchemaMethod = fileScanTaskClass.getMethod("schema")
-    val toJsonMethod = schemaParserClass.getMethod("toJson", schemaClass)
-    toJsonMethod.setAccessible(true)
+    val keyMetadataMethod = IcebergReflection.getMethod(contentFileClass, 
"keyMetadata")
+    val taskSchemaMethod = IcebergReflection.getMethod(fileScanTaskClass, 
"schema")
+    val toJsonMethod =
+      IcebergReflection.getMethod(schemaParserClass, "toJson", schemaClass)
 
     // Access inputRDD - safe now, DPP is resolved
     scanExec.inputRDD match {
@@ -972,12 +1003,11 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
             val inputPartClass = inputPartition.getClass
 
             {
-              val taskGroupMethod = 
inputPartClass.getDeclaredMethod("taskGroup")
-              taskGroupMethod.setAccessible(true)
+              val taskGroupMethod =
+                IcebergReflection.getDeclaredMethod(inputPartClass, 
"taskGroup")
               val taskGroup = taskGroupMethod.invoke(inputPartition)
 
-              val taskGroupClass = taskGroup.getClass
-              val tasksMethod = taskGroupClass.getMethod("tasks")
+              val tasksMethod = 
IcebergReflection.getMethod(taskGroup.getClass, "tasks")
               val tasksCollection =
                 
tasksMethod.invoke(taskGroup).asInstanceOf[java.util.Collection[_]]
 
@@ -1030,7 +1060,7 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
                     // DeleteFilter.fileProjection).
                     val equalityFieldIds = deletes.asScala.flatMap { df =>
                       IcebergReflection
-                        .getEqualityFieldIds(df)
+                        .getEqualityFieldIds(deleteFileClass, df)
                         .asScala
                         .map(_.asInstanceOf[java.lang.Integer].intValue())
                     }.toSeq
@@ -1042,17 +1072,6 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
                       taskSchema
                     }
                   } else {
-                    val scanSchemaFieldIds = IcebergReflection
-                      .buildFieldIdMapping(metadata.scanSchema)
-                      .values
-                      .toSet
-                    val tableSchemaFieldIds = IcebergReflection
-                      .buildFieldIdMapping(metadata.tableSchema)
-                      .values
-                      .toSet
-                    val hasHistoricalColumns =
-                      scanSchemaFieldIds.exists(id => 
!tableSchemaFieldIds.contains(id))
-
                     if (hasHistoricalColumns) {
                       metadata.scanSchema.asInstanceOf[AnyRef]
                     } else {
@@ -1069,7 +1088,7 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
                   })
                 taskBuilder.setSchemaIdx(schemaIdx)
 
-                val nameToFieldId = 
IcebergReflection.buildFieldIdMapping(schema)
+                val nameToFieldId = fieldIdMapping(schema)
 
                 val projectFieldIds = output.map { attr =>
                   nameToFieldId
@@ -1095,7 +1114,11 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
                 taskBuilder.setProjectFieldIdsIdx(projectFieldIdsIdx)
 
                 val deleteFilesList =
-                  extractDeleteFilesList(task, contentFileClass, 
fileScanTaskClass)
+                  extractDeleteFilesList(
+                    task,
+                    contentFileClass,
+                    fileScanTaskClass,
+                    deleteFileClass)
                 if (deleteFilesList.nonEmpty) {
                   // Intern each delete file into the flat pool, then dedup 
this task's set as the
                   // resulting list of pool indices.
@@ -1146,6 +1169,7 @@ object CometIcebergNativeScan extends 
CometOperatorSerde[CometBatchScanExec] wit
                   task,
                   contentScanTaskClass,
                   fileScanTaskClass,
+                  partitionSpecToJson,
                   taskBuilder,
                   commonBuilder,
                   partitionSpecToPoolIndex,
diff --git 
a/spark/src/test/scala/org/apache/comet/iceberg/IcebergReflectionSuite.scala 
b/spark/src/test/scala/org/apache/comet/iceberg/IcebergReflectionSuite.scala
index 2ec5f5ac73..64657bffcb 100644
--- a/spark/src/test/scala/org/apache/comet/iceberg/IcebergReflectionSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/iceberg/IcebergReflectionSuite.scala
@@ -19,12 +19,15 @@
 
 package org.apache.comet.iceberg
 
+import java.lang.reflect.Modifier
 import java.util.Collections
 
 import org.scalatest.funsuite.AnyFunSuite
 
 import org.apache.iceberg.BaseMetastoreTableOperations
 import org.apache.iceberg.BaseTable
+import org.apache.iceberg.DataFiles
+import org.apache.iceberg.PartitionSpec
 import org.apache.iceberg.Schema
 import org.apache.iceberg.TableMetadata
 import org.apache.iceberg.io.FileIO
@@ -44,7 +47,7 @@ class IcebergReflectionSuite extends AnyFunSuite {
     val schema = new Schema(Types.NestedField.required(1, "id", 
Types.IntegerType.get()))
     val expectedMetadata = TableMetadata.newTableMetadata(
       schema,
-      org.apache.iceberg.PartitionSpec.unpartitioned(),
+      PartitionSpec.unpartitioned(),
       "file:///tmp/test-table",
       Collections.emptyMap[String, String]())
     val metadataField = classOf[BaseMetastoreTableOperations]
@@ -63,4 +66,97 @@ class IcebergReflectionSuite extends AnyFunSuite {
     assert(metadata.isDefined)
     assert(metadata.get.isInstanceOf[TableMetadata])
   }
+
+  test("findMethod resolves a method once and returns the cached instance") {
+    val first = IcebergReflection.findMethod(classOf[Schema], "columns")
+    val second = IcebergReflection.findMethod(classOf[Schema], "columns")
+    assert(first.isDefined)
+    assert(first.get.getName == "columns")
+    // Class.getMethod hands back a fresh copy per call; the cache must not.
+    assert(first.get eq second.get)
+  }
+
+  test("an absent method is a cached miss, and getMethod still throws for it") 
{
+    assert(IcebergReflection.findMethod(classOf[Schema], 
"noSuchAccessor").isEmpty)
+    assert(IcebergReflection.findMethod(classOf[Schema], 
"noSuchAccessor").isEmpty)
+    assertThrows[NoSuchMethodException] {
+      IcebergReflection.getMethod(classOf[Schema], "noSuchAccessor")
+    }
+  }
+
+  test("findMethod distinguishes overloads by parameter type") {
+    val byId = IcebergReflection.findMethod(classOf[Schema], "findField", 
classOf[Int])
+    val byName = IcebergReflection.findMethod(classOf[Schema], "findField", 
classOf[String])
+    assert(byId.isDefined && byName.isDefined)
+    assert(byId.get ne byName.get)
+
+    val schema = new Schema(Types.NestedField.required(7, "id", 
Types.IntegerType.get()))
+    val fieldById = byId.get.invoke(schema, 
Integer.valueOf(7)).asInstanceOf[Types.NestedField]
+    val fieldByName = byName.get.invoke(schema, 
"id").asInstanceOf[Types.NestedField]
+    assert(fieldById.name() == "id")
+    assert(fieldByName.fieldId() == 7)
+  }
+
+  test("findMethodInHierarchy finds an inherited method and caches it") {
+    val first = 
IcebergReflection.findMethodInHierarchy(classOf[StubTableOperations], "current")
+    val second = 
IcebergReflection.findMethodInHierarchy(classOf[StubTableOperations], "current")
+    assert(first.isDefined)
+    // current() is declared on BaseMetastoreTableOperations, not on the stub 
itself.
+    assert(first.get.getDeclaringClass == 
classOf[BaseMetastoreTableOperations])
+    assert(first.get eq second.get)
+    
assert(IcebergReflection.findMethodInHierarchy(classOf[StubTableOperations], 
"nope").isEmpty)
+  }
+
+  test("extractFileLocation reads location() when the class has one") {
+    val file = new LocationFile("s3://bucket/data/f.parquet")
+    assert(
+      IcebergReflection.extractFileLocation(classOf[LocationFile], file) ==
+        Some("s3://bucket/data/f.parquet"))
+  }
+
+  test("extractFileLocation falls back to path() on Iceberg versions without 
location()") {
+    val file = new PathOnlyFile("s3://bucket/data/f.parquet")
+    // Called twice: the second call reads the cached "location() is absent" 
answer.
+    assert(
+      IcebergReflection.extractFileLocation(classOf[PathOnlyFile], file) ==
+        Some("s3://bucket/data/f.parquet"))
+    assert(
+      IcebergReflection.extractFileLocation(classOf[PathOnlyFile], file) ==
+        Some("s3://bucket/data/f.parquet"))
+  }
+
+  test("extractFileLocation returns None when the class exposes neither 
accessor") {
+    assert(IcebergReflection.extractFileLocation(classOf[Object], new 
Object).isEmpty)
+  }
+
+  test("a resolved method has access checks suppressed") {
+    // Iceberg's concrete file impls are package-private (a built DataFile is 
a GenericDataFile,
+    // and its accessors are declared on the equally package-private 
BaseFile), so an accessor
+    // resolved on one is not invocable from Comet's package until 
setAccessible has run. The
+    // modifier assertions keep the test from going vacuous if Iceberg ever 
makes them public.
+    val file = DataFiles
+      .builder(PartitionSpec.unpartitioned())
+      .withPath("/tmp/data/f.parquet")
+      .withFileSizeInBytes(10)
+      .withRecordCount(1)
+      .withFormat("PARQUET")
+      .build()
+    assert(!Modifier.isPublic(file.getClass.getModifiers))
+
+    val method = IcebergReflection.findMethod(file.getClass, "path")
+    assert(method.isDefined)
+    assert(!Modifier.isPublic(method.get.getDeclaringClass.getModifiers))
+    // Without makeAccessible this invoke throws IllegalAccessException.
+    assert(method.get.invoke(file).toString == "/tmp/data/f.parquet")
+  }
+
+  /** Mimics a newer Iceberg ContentFile, which exposes location(). */
+  class LocationFile(loc: String) {
+    def location(): String = loc
+  }
+
+  /** Mimics Iceberg before 1.7, where ContentFile only exposed path(): 
CharSequence. */
+  class PathOnlyFile(p: String) {
+    def path(): CharSequence = p
+  }
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to