andygrove commented on code in PR #5361:
URL: https://github.com/apache/datafusion-comet/pull/5361#discussion_r3905939883
##########
docs/source/user-guide/latest/iceberg-writes.md:
##########
@@ -156,23 +170,64 @@ identically on both paths, WAP / branch / snapshot
properties act on the JVM com
settings route the write through `WriteDelta`, which the split plan never
intercepts. Every
rule is pinned by `CometIcebergWriteDetectionSuite`.
-Manifest `DataFile` metrics will be assembled on the JVM at commit time using
Iceberg's own
-`MetricsConfig` logic, so iceberg-java's metadata decisions — metrics modes,
the
-inferred-column cap (`write.metadata.metrics.max-inferred-column-defaults`),
bound truncation,
-and list/map bounds suppression — are respected exactly regardless of what the
native writer
-reports. The `counts`/`none` restrictions above remain only until that
assembly lands.
+Manifest `DataFile` metrics are assembled on the JVM before commit: each
written file's
+metrics are re-derived from its parquet footer through the version-matched
+`ParquetUtil.footerMetrics` and `MetricsConfig.forTable`, with float/double
NaN counts and
+bounds carried over from the native writer's tracked state. iceberg-java's
metadata decisions
+— metrics modes, the inferred-column cap
+(`write.metadata.metrics.max-inferred-column-defaults`), bound truncation, and
list/map bounds
+suppression — are therefore applied by iceberg-java's own code regardless of
what the native
+writer reports. This costs one footer-sized ranged read per written file at
write time.
+
+## Failure handling
+
+Eligibility is decided entirely at plan time. That includes the reflection
surface: every
+iceberg-java class, method, and constructor the executor-side commit-message
assembly uses is
+eagerly resolved by the eligibility gate on the driver, so an Iceberg release
that moves any
+of them declines the native path with a fall-back reason instead of failing
tasks mid-write.
+Once planned, the physical plan is fixed — there is no per-task re-decision or
runtime switch
+back to the JVM writer.
+
+When a native write fails partway through a task (an object-store error, a
data-dependent cast
+failure), the error propagates as an ordinary Spark task failure and Spark's
task retry
+re-executes it — through the native writer again. Retries cannot collide: each
attempt's task
+attempt id is embedded in its data file names.
+
+Partial results are never committed. The commit set is exactly the commit
messages returned by
+successful tasks — a failed task contributes none — and if the job fails, the
driver-side
+commit operator aborts without committing anything. Data files already
finalized by a failed
+task attempt are not deleted by that task (iceberg-java's writer abort deletes
them; the
+native path has no abort hook yet): they are invisible to every reader, since
readers resolve
+files through committed manifests only, and are reclaimed by Iceberg's normal
Review Comment:
This is honest documentation of a real gap, and I appreciate that it's
called out rather than glossed. My worry is that it will not turn into work
unless it gets a tracking issue. In practice this is a per-task-failure orphan
cost that iceberg-java's `SparkPartitionedDataWriter` cleans up synchronously
via its abort hook. On a well-behaved cluster it is invisible; on spot-heavy or
preemption-prone environments it accumulates until someone remembers to
schedule `remove_orphan_files`, and users on the native path may not know
they've opted in to that trade until they hit it.
Could you file a tracking issue for the abort hook (Spark's
`TaskContext.addTaskFailureListener` is the natural spot, mirroring what
`SparkPartitionedDataWriter` does) and link it from this sentence? A pointer
here makes the operational cost visible to users reading the compat page, and
makes it easy for the follow-up to close it out.
##########
spark/src/main/scala/org/apache/comet/iceberg/IcebergReflection.scala:
##########
@@ -1110,6 +1268,607 @@ object IcebergReflection extends Logging {
logError(s"Iceberg reflection failure: Failed to get data location:
${e.getMessage}", e)
None
}
+
+ /**
+ * Returns the top-level column names of an Iceberg `Schema`, in declared
order. Used by the
+ * native write serde to project Spark 4.x `ReplaceData` row streams (which
carry an
+ * `__row_operation` column plus optional file-metadata columns) down to
just the data columns
+ * the native iceberg-rust writer expects.
+ */
+ def getSchemaFieldNames(schema: Any): Option[Seq[String]] =
+ try {
+ val cols = schema.getClass
+ .getMethod("columns")
+ .invoke(schema)
+ .asInstanceOf[java.util.List[_]]
+ val names = new scala.collection.mutable.ArrayBuffer[String](cols.size())
+ val it = cols.iterator()
+ while (it.hasNext) {
+ val col = it.next().asInstanceOf[AnyRef]
+ names +=
col.getClass.getMethod("name").invoke(col).asInstanceOf[String]
+ }
+ Some(names.toSeq)
+ } catch {
+ case e: Exception =>
+ logError(s"Iceberg reflection failure: Schema.columns():
${e.getMessage}")
+ None
+ }
+
+ /**
+ * Finds the first field -- nested struct/list/map fields included -- whose
Iceberg type's
+ * `TypeID` name is in `typeIds` (e.g. `Set("UUID")`), returning its `(name,
typeId)`. Used by
+ * write detection to decline schemas containing types the native writer
cannot reproduce.
+ * Reflection failures are deliberately not swallowed: the caller's
detection wrapper turns them
+ * into a fall-back, so a failed walk reads as "cannot verify" rather than
"supported".
+ */
+ def findFieldWithTypeIds(schema: Any, typeIds: Set[String]): Option[(String,
String)] = {
+ val queue = new java.util.ArrayDeque[AnyRef]()
+ queue.addAll(
+
schema.getClass.getMethod("columns").invoke(schema).asInstanceOf[java.util.List[AnyRef]])
+ while (!queue.isEmpty) {
+ val field = queue.poll()
+ val fieldType = field.getClass.getMethod("type").invoke(field)
+ val typeId = fieldType.getClass.getMethod("typeId").invoke(fieldType)
+ val typeIdName = typeId.asInstanceOf[Enum[_]].name()
+ if (typeIds.contains(typeIdName)) {
+ val name =
field.getClass.getMethod("name").invoke(field).asInstanceOf[String]
+ return Some((name, typeIdName))
+ }
+ val isNested =
+
fieldType.getClass.getMethod("isNestedType").invoke(fieldType).asInstanceOf[Boolean]
+ if (isNested) {
+ val nested =
fieldType.getClass.getMethod("asNestedType").invoke(fieldType)
+ queue.addAll(
+ nested.getClass
+ .getMethod("fields")
+ .invoke(nested)
+ .asInstanceOf[java.util.List[AnyRef]])
+ }
+ }
+ None
+ }
+
+ /**
+ * Sum `recordCount` and `fileSizeInBytes` across `dataFiles` for SQL-metric
reporting. The
+ * concrete `DataFile` impl (`BaseFile`) is package-private in Iceberg, so
look the accessors up
+ * on the public `DataFile` interface instead; virtual dispatch still hits
the concrete
+ * implementation at invoke time.
+ */
+ def sumDataFileMetrics(dataFiles: java.util.List[_]): (Long, Long) = {
+ if (dataFiles.isEmpty) return (0L, 0L)
+ val dataFileClass = loadClass(ClassNames.DATA_FILE)
+ val recordCountMethod = dataFileClass.getMethod("recordCount")
+ val fileSizeMethod = dataFileClass.getMethod("fileSizeInBytes")
+ var rows = 0L
+ var bytes = 0L
+ val it = dataFiles.iterator()
+ while (it.hasNext) {
+ val df = it.next().asInstanceOf[AnyRef]
+ rows +=
recordCountMethod.invoke(df).asInstanceOf[java.lang.Long].longValue()
+ bytes +=
fileSizeMethod.invoke(df).asInstanceOf[java.lang.Long].longValue()
+ }
+ (rows, bytes)
+ }
+
+ /**
+ * Looks up a `SortOrder` from `Table.sortOrders()` by its id. Used to
recover the sort order
+ * the write was planned against (`outputSortOrderId`) so it can be
re-applied to the decoded
+ * `DataFile`s through the public `DataFiles.Builder.withSortOrder` --
iceberg-rust's writer
+ * doesn't expose the field, so the manifest comes back with `sort_order_id`
unset. `SortOrder`
+ * is `Serializable`, so the result can ship in a task closure.
+ *
+ * Id 0 falls back to `SortOrder.unsorted()` when absent from the map: a
write whose
+ * `outputSortOrderId` resolves to unsorted (no ordering required) may run
against a table whose
+ * metadata only records its non-trivial sort orders.
+ */
+ def getSortOrderById(table: Any, sortOrderId: Int): Option[AnyRef] =
+ try {
+ val method = table.getClass.getMethod("sortOrders")
+ val orders =
method.invoke(table).asInstanceOf[java.util.Map[java.lang.Integer, AnyRef]]
+ Option(orders.get(java.lang.Integer.valueOf(sortOrderId))).orElse {
+ if (sortOrderId == 0) {
+ val sortOrderClass = loadClass("org.apache.iceberg.SortOrder")
+ Some(sortOrderClass.getMethod("unsorted").invoke(null))
+ } else {
+ None
+ }
+ }
+ } catch {
+ case e: Exception =>
+ logError(
+ "Iceberg reflection failure: Failed to look up sort order " +
+ s"$sortOrderId: ${e.getMessage}")
+ None
+ }
+
+ /**
+ * Construct a `SparkWrite$TaskCommit(DataFile[])` instance for the native
commit path. The
+ * constructor is package-private; `setAccessible(true)` is required on
every Iceberg version.
+ */
+ def buildTaskCommit(dataFiles: java.util.List[_]): AnyRef = {
+ val taskCommitClass =
loadClass("org.apache.iceberg.spark.source.SparkWrite$TaskCommit")
+ val dataFileClass = loadClass("org.apache.iceberg.DataFile")
+ val arrayClass = java.lang.reflect.Array.newInstance(dataFileClass,
0).getClass
+ val ctor = taskCommitClass.getDeclaredConstructor(arrayClass)
+ ctor.setAccessible(true)
+ val array = java.lang.reflect.Array.newInstance(dataFileClass,
dataFiles.size())
+ for (i <- 0 until dataFiles.size()) {
+ java.lang.reflect.Array.set(array, i, dataFiles.get(i))
+ }
+ ctor.newInstance(array.asInstanceOf[AnyRef]).asInstanceOf[AnyRef]
+ }
+
+ /**
+ * Eagerly resolves every class, method, and constructor the executor-side
commit-message
+ * assembly reflects on (`decodeManifestToDataFiles`,
`rebuildDataFilesWithJavaMetrics`,
+ * `sumDataFileMetrics`, `buildTaskCommit`). That code runs after
iceberg-rust has already
+ * written the task's data files, so a reflection miss there is a task
failure; probing the full
+ * surface from the eligibility gate turns an Iceberg release that moves any
of it into a
+ * plan-time fallback instead. Memoized: the linked Iceberg cannot change
within a JVM, and
Review Comment:
Small thing on the memoization scope. `lazy val` on a Scala object caches
per JVM, not per Spark session. The comment says "the linked Iceberg cannot
change within a JVM," and for a long-running driver that is *almost* always
true — but `SparkSession.sql("ADD JAR /path/to/iceberg-1.9.jar")` and
`spark-shell --jars`-style dynamic classpath changes can shift what
`loadClass(...)` resolves to mid-run, and a memoized `Some("...")` from the
first probe would then persist even after the classpath was fixed. The reverse
is worse: a first probe that resolved successfully against an old Iceberg gets
cached as `None`, and a later `ADD JAR` to a moved release would then let
ineligible writes through.
Either the comment should soften the claim ("unless dynamic classpath
changes intervene") or the memoization could be keyed by the resolved Iceberg
class-loader identity, whichever you prefer. This is not a real hazard in the
common deployment shape, but the current comment is more absolute than the
reality.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]