Skip to content

Commit bba4744

Browse files
authored
Distinguish reflection failure from absence (#5412)
1 parent 22c74c1 commit bba4744

3 files changed

Lines changed: 105 additions & 40 deletions

File tree

spark/src/main/scala/org/apache/comet/iceberg/IcebergReflection.scala

Lines changed: 24 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -329,46 +329,34 @@ object IcebergReflection extends Logging {
329329
* Different Iceberg versions expose file paths differently:
330330
* - Newer versions: location() returns String
331331
* - Older versions: path() returns CharSequence
332+
*
333+
* `None` means neither accessor is declared; a genuine invoke failure propagates instead.
332334
*/
333-
def extractFileLocation(contentFileClass: Class[_], file: Any): Option[String] = {
334-
try {
335-
findMethod(contentFileClass, "location") match {
336-
case Some(locationMethod) => Some(locationMethod.invoke(file).asInstanceOf[String])
337-
case None =>
338-
findMethod(contentFileClass, "path")
339-
.map(_.invoke(file).asInstanceOf[CharSequence].toString)
340-
}
341-
} catch {
342-
case _: Exception => None
335+
def extractFileLocation(contentFileClass: Class[_], file: Any): Option[String] =
336+
findMethod(contentFileClass, "location") match {
337+
case Some(locationMethod) => Some(locationMethod.invoke(file).asInstanceOf[String])
338+
case None =>
339+
findMethod(contentFileClass, "path")
340+
.map(_.invoke(file).asInstanceOf[CharSequence].toString)
343341
}
344-
}
345342

346343
/**
347344
* Extracts file location from ContentFile instance using dynamic class lookup.
348345
*/
349-
def extractFileLocation(file: Any): Option[String] = {
350-
try {
351-
val contentFileClass = loadClass(ClassNames.CONTENT_FILE)
352-
extractFileLocation(contentFileClass, file)
353-
} catch {
354-
case _: Exception => None
355-
}
356-
}
346+
def extractFileLocation(file: Any): Option[String] =
347+
tryLoadClass(ClassNames.CONTENT_FILE).flatMap(extractFileLocation(_, file))
357348

358349
/**
359350
* The file format of a ContentFile (data or delete file), e.g. "PARQUET", "AVRO", "ORC".
360351
*
361352
* `contentFileClass` is the public ContentFile interface, which callers already hold: Iceberg's
362353
* concrete file impls are package-private, so `format()` resolved on the concrete class throws
363354
* IllegalAccessException when invoked.
355+
*
356+
* `None` means `format()` isn't declared; a genuine invoke failure propagates instead.
364357
*/
365-
def getFileFormat(contentFileClass: Class[_], file: Any): Option[String] = {
366-
try {
367-
findMethod(contentFileClass, "format").map(_.invoke(file).toString)
368-
} catch {
369-
case _: Exception => None
370-
}
371-
}
358+
def getFileFormat(contentFileClass: Class[_], file: Any): Option[String] =
359+
findMethod(contentFileClass, "format").map(_.invoke(file).toString)
372360

373361
/**
374362
* Gets the Iceberg Table from a SparkScan.
@@ -786,20 +774,18 @@ object IcebergReflection extends Logging {
786774
* An Iceberg DeleteFile object
787775
* @return
788776
* List of field IDs used in equality deletes, or empty list for position deletes
777+
*
778+
* Empty means either `equalityFieldIds()` isn't declared, or it returned `null` (Iceberg's
779+
* normal contract for a position-delete file). A genuine invoke failure propagates instead of
780+
* collapsing into empty.
789781
*/
790-
def getEqualityFieldIds(deleteFileClass: Class[_], deleteFile: Any): java.util.List[_] = {
791-
try {
792-
val ids =
793-
getMethod(deleteFileClass, "equalityFieldIds")
794-
.invoke(deleteFile)
795-
.asInstanceOf[java.util.List[_]]
796-
if (ids == null) new java.util.ArrayList[Any]() else ids
797-
} catch {
798-
case _: Exception =>
799-
// Position delete files return null/empty for equalityFieldIds
800-
new java.util.ArrayList[Any]()
782+
def getEqualityFieldIds(deleteFileClass: Class[_], deleteFile: Any): java.util.List[_] =
783+
findMethod(deleteFileClass, "equalityFieldIds") match {
784+
case None => new java.util.ArrayList[Any]()
785+
case Some(method) =>
786+
val ids = method.invoke(deleteFile).asInstanceOf[java.util.List[_]]
787+
if (ids == null) new java.util.ArrayList[Any]() else ids
801788
}
802-
}
803789

804790
/**
805791
* Gets field name and type from schema by field ID.

spark/src/main/scala/org/apache/comet/serde/operator/CometIcebergNativeScan.scala

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,9 @@ object CometIcebergNativeScan extends CometOperatorSerde[CometBatchScanExec] wit
286286
val deletePath = IcebergReflection
287287
.extractFileLocation(contentFileClass, deleteFile)
288288
.getOrElse(
289-
throw new RuntimeException("Failed to extract delete file path from FileScanTask"))
289+
throw new RuntimeException(
290+
"Neither location() nor path() is declared on this Iceberg version's " +
291+
"ContentFile -- cannot extract delete file path from FileScanTask"))
290292

291293
val deleteBuilder = OperatorOuterClass.IcebergDeleteFile.newBuilder()
292294
deleteBuilder.setFilePath(deletePath)
@@ -1026,7 +1028,8 @@ object CometIcebergNativeScan extends CometOperatorSerde[CometBatchScanExec] wit
10261028
taskBuilder.setDataFilePath(filePath)
10271029
case None =>
10281030
val msg =
1029-
"Iceberg reflection failure: Cannot extract file path from data file"
1031+
"Neither location() nor path() is declared on this Iceberg version's " +
1032+
"ContentFile -- cannot extract file path from data file"
10301033
logError(msg)
10311034
throw new RuntimeException(msg)
10321035
}

spark/src/test/scala/org/apache/comet/iceberg/IcebergReflectionSuite.scala

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,54 @@ class IcebergReflectionSuite extends AnyFunSuite {
129129
assert(IcebergReflection.extractFileLocation(classOf[Object], new Object).isEmpty)
130130
}
131131

132+
test("extractFileLocation propagates a genuine invoke failure instead of returning None") {
133+
val file = new ThrowingLocationFile
134+
val ex = intercept[java.lang.reflect.InvocationTargetException] {
135+
IcebergReflection.extractFileLocation(classOf[ThrowingLocationFile], file)
136+
}
137+
assert(ex.getCause.getMessage == "boom")
138+
}
139+
140+
test("getFileFormat reads format() when declared") {
141+
val file = new FormatFile("PARQUET")
142+
assert(IcebergReflection.getFileFormat(classOf[FormatFile], file) == Some("PARQUET"))
143+
}
144+
145+
test("getFileFormat returns None when format() is not declared") {
146+
assert(IcebergReflection.getFileFormat(classOf[Object], new Object).isEmpty)
147+
}
148+
149+
test("getFileFormat propagates a genuine invoke failure instead of returning None") {
150+
val file = new ThrowingFormatFile
151+
val ex = intercept[java.lang.reflect.InvocationTargetException] {
152+
IcebergReflection.getFileFormat(classOf[ThrowingFormatFile], file)
153+
}
154+
assert(ex.getCause.getMessage == "boom")
155+
}
156+
157+
test("getEqualityFieldIds reads declared equality field ids") {
158+
val ids = java.util.List.of(Integer.valueOf(3), Integer.valueOf(5))
159+
val file = new EqualityIdsFile(ids)
160+
assert(IcebergReflection.getEqualityFieldIds(classOf[EqualityIdsFile], file) == ids)
161+
}
162+
163+
test("getEqualityFieldIds treats a null return (position delete) as empty, not a failure") {
164+
val file = new NullEqualityIdsFile
165+
assert(IcebergReflection.getEqualityFieldIds(classOf[NullEqualityIdsFile], file).isEmpty)
166+
}
167+
168+
test("getEqualityFieldIds returns empty when equalityFieldIds() is not declared") {
169+
assert(IcebergReflection.getEqualityFieldIds(classOf[Object], new Object).isEmpty)
170+
}
171+
172+
test("getEqualityFieldIds propagates a genuine invoke failure instead of returning empty") {
173+
val file = new ThrowingEqualityIdsFile
174+
val ex = intercept[java.lang.reflect.InvocationTargetException] {
175+
IcebergReflection.getEqualityFieldIds(classOf[ThrowingEqualityIdsFile], file)
176+
}
177+
assert(ex.getCause.getMessage == "boom")
178+
}
179+
132180
test("a resolved method has access checks suppressed") {
133181
// Iceberg's concrete file impls are package-private (a built DataFile is a GenericDataFile,
134182
// and its accessors are declared on the equally package-private BaseFile), so an accessor
@@ -159,4 +207,32 @@ class IcebergReflectionSuite extends AnyFunSuite {
159207
class PathOnlyFile(p: String) {
160208
def path(): CharSequence = p
161209
}
210+
211+
/** location() is declared (not a version difference) but the call itself fails. */
212+
class ThrowingLocationFile {
213+
def location(): String = throw new RuntimeException("boom")
214+
}
215+
216+
class FormatFile(fmt: String) {
217+
def format(): String = fmt
218+
}
219+
220+
/** format() is declared but the call itself fails. */
221+
class ThrowingFormatFile {
222+
def format(): String = throw new RuntimeException("boom")
223+
}
224+
225+
class EqualityIdsFile(ids: java.util.List[Integer]) {
226+
def equalityFieldIds(): java.util.List[Integer] = ids
227+
}
228+
229+
/** Mimics a position-delete file: the accessor is declared and returns null, not a failure. */
230+
class NullEqualityIdsFile {
231+
def equalityFieldIds(): java.util.List[Integer] = null
232+
}
233+
234+
/** equalityFieldIds() is declared but the call itself fails. */
235+
class ThrowingEqualityIdsFile {
236+
def equalityFieldIds(): java.util.List[Integer] = throw new RuntimeException("boom")
237+
}
162238
}

0 commit comments

Comments
 (0)