andygrove commented on code in PR #5331:
URL: https://github.com/apache/datafusion-comet/pull/5331#discussion_r3872675077


##########
spark/src/main/scala/org/apache/comet/iceberg/IcebergReflection.scala:
##########
@@ -935,6 +935,32 @@ object IcebergReflection extends Logging {
     }
   }
 
+  /**
+   * Names of top-level columns whose Iceberg sort order can differ from 
Spark's comparison of the
+   * Spark type they map to, so a native k-way merge keyed on the Spark value 
could mis-order
+   * rows. Today that is UUID: Iceberg maps UUID to Spark StringType (see 
TypeToSparkType) but
+   * sorts by its own UUID comparator, not by the canonical string, so the 
file order and a string
+   * comparison can disagree. reportableOrdering refuses a sort key in this 
set. v1 only reports
+   * identity, top-level sort keys, so top-level columns are enough.
+   */
+  def orderingUnsafeColumns(schema: Any): Set[String] = {
+    import scala.jdk.CollectionConverters._
+    try {
+      val columns = getMethod(schema.getClass, "columns")
+        .invoke(schema)
+        .asInstanceOf[java.util.List[_]]
+      columns.asScala.flatMap { column =>
+        val name = getMethod(column.getClass, 
"name").invoke(column).asInstanceOf[String]
+        val typeStr = getMethod(column.getClass, 
"type").invoke(column).toString
+        if (typeStr == "uuid") Some(name) else None
+      }.toSet
+    } catch {
+      case e: Exception =>
+        logWarning(s"Failed to inspect schema for ordering-unsafe columns: 
${e.getMessage}")

Review Comment:
   This returns `Set.empty` when the reflection fails, which the callers read 
as "no unsafe columns", so they go on to report the ordering. That's the 
fail-open direction on a decision where being wrong means silently mis-ordered 
rows, because Spark has already dropped the `Sort` by the time we get here.
   
   The premise of this method is that a UUID sort key is invisible at the Spark 
type level. If we couldn't read the schema, we can't rule one out. Could the 
failure path make the ordering unreportable instead? Returning something like 
`None` and having `reportableOrdering` treat that as "refuse" would keep the 
safe direction the default.
   
   The `nativeIcebergScanMetadata == null` branch in 
`CometIcebergNativeScanExec.outputOrdering` has the same shape.



##########
spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala:
##########
@@ -835,12 +835,38 @@ case class CometScanRule(session: SparkSession)
           }
         }
 
+        // If Iceberg reports an ordering, EnsureRequirements may have already 
dropped the Sort
+        // above this scan (it decides that on the vanilla BatchScanExec, 
before Comet converts the
+        // scan). If the native scan cannot guarantee that ordering, reading 
unordered here would
+        // silently return wrong results, so stay on Spark -- its Iceberg 
reader produces the sorted
+        // output it promised. reportableOrdering is the same gate the native 
scan/serde use, so the
+        // decision here cannot diverge from what the native path would do.
+        val orderingHonored: Boolean = {

Review Comment:
   Thanks for collapsing the proto and `outputOrdering` down to one evaluation. 
I think this guard reintroduces the same hazard one level up, though.
   
   This is a second evaluation of `reportableOrdering`, and it's the one that 
decides whether Comet converts the scan at all. It reads 
`COMET_ICEBERG_SORT_MERGE_ENABLED` out of the active `SQLConf` and runs 
`orderingUnsafeColumns` reflection independently of the exec's lazy val. If 
this one says honorable, Comet converts and Spark drops the `Sort`. If the 
later one returns `Nil`, native reads unordered and we return wrong results 
with no error. The reverse mismatch just wastes some work.
   
   You already have `honorable` right here. Could we stash it on 
`nativeIcebergScanMetadata` and have 
`CometIcebergNativeScanExec.outputOrdering` read that instead of re-running the 
gate? Then there's one evaluation for the whole decision, and no conf read 
after the point where Spark has committed to dropping the sort.



##########
native/core/src/execution/spark_config.rs:
##########
@@ -26,6 +26,21 @@ pub(crate) const COMET_PARQUET_ROW_FILTER_PUSHDOWN_ENABLED: 
&str =
     "spark.comet.parquet.rowFilterPushdown.enabled";
 pub(crate) const SPARK_EXECUTOR_CORES: &str = "spark.executor.cores";
 
+/// Above this many files in a single Spark partition, the native Iceberg scan 
does not attempt a
+/// per-file-stream k-way merge (which would open one reader per file at 
once); it reads the
+/// partition unordered and sorts with a spillable SortExec instead. Read from 
the config below and
+/// carried to the physical planner as an [`IcebergSortMergeConfig`] session 
extension.
+pub(crate) const COMET_ICEBERG_SORT_MERGE_MAX_FILES_PER_PARTITION: &str =
+    "spark.comet.scan.icebergNative.sortMerge.maxFilesPerPartition";
+pub(crate) const DEFAULT_ICEBERG_SORT_MERGE_MAX_FILES_PER_PARTITION: usize = 
64;

Review Comment:
   `data_file_concurrency_limit` is the same kind of setting and it rides on 
`IcebergScanCommon`, set from `CometConf` in the serde, so there's exactly one 
default. This one goes through the Spark config map plus a `SessionConfig` 
extension with `64` written on both sides of JNI, and nothing catches it if the 
two drift.
   
   Is there a reason not to put `max_files_per_partition` on 
`IcebergScanCommon` next to `data_file_concurrency_limit`? That removes the 
duplicated default and the extension plumbing in one go.
   
   Related: both `iceberg_scan_merges_when_files_within_limit` and 
`iceberg_scan_falls_back_to_sort_above_file_limit` use 
`PhysicalPlanner::default()`, so they only ever test the compiled-in constant. 
If the extension weren't reaching the planner both would still pass, and the 
Scala test that sets the conf to `1000` would quietly exercise the sort 
fallback instead of the 70-way merge it's written to cover. Worth one test that 
builds the planner from a `SessionConfig` carrying `IcebergSortMergeConfig { 
max_files_per_partition: 2 }` and asserts a 3-file scan takes the `SortExec` 
path.



##########
spark/src/main/scala/org/apache/comet/CometConf.scala:
##########
@@ -121,6 +121,30 @@ object CometConf extends ShimCometConf {
       .booleanConf
       .createWithDefault(true)
 
+  val COMET_ICEBERG_SORT_MERGE_ENABLED: ConfigEntry[Boolean] =
+    conf("spark.comet.scan.icebergNative.sortMerge.enabled")
+      .category(CATEGORY_SCAN)
+      .doc("Whether the native Iceberg scan reports the table sort order and 
performs a " +
+        "per-partition streaming merge of already-sorted files. When enabled 
and Iceberg reports " +
+        "an ordering (requires Iceberg's 
spark.sql.iceberg.planning.preserve-data-ordering), " +
+        "each Spark partition reads its files as separate sorted streams 
merged into one sorted " +
+        "output, and the ordering is surfaced to Spark so redundant sorts are 
eliminated. When " +
+        "disabled, files are read unordered as before.")

Review Comment:
   The doc says "When disabled, files are read unordered as before", but with 
the new `orderingHonored` guard that isn't what happens. Disabling this makes 
`reportableOrdering` return `Nil`, which makes `orderingHonored` false, which 
keeps the scan on Spark entirely. Your own two `fallback: sort-merge disabled 
...` tests assert exactly that. So the escape hatch for a merge bug also costs 
all Comet acceleration on every sorted Iceberg table, which is a steep price 
for the knob people would reach for when something looks wrong.
   
   I don't think it has to work that way. `maxFilesPerPartition = 0` already 
gives us "report the ordering, never merge, honor it with the native 
`SortExec`", which is correct and keeps Comet on the scan. Could 
`sortMerge.enabled=false` mean that instead? Either way the doc needs to 
describe the actual behavior.



##########
native/core/src/execution/operators/iceberg_scan.rs:
##########
@@ -86,6 +86,24 @@ pub struct IcebergScanExec {
     tasks: Vec<FileScanTask>,
     /// Number of data files to read concurrently
     data_file_concurrency_limit: usize,
+    /// FileIO (and, for S3, the JVM credential bridge behind it) built once 
at plan time and shared
+    /// across partitions. FileIO is cheap to clone (Arc-backed), so each 
`execute` clones this
+    /// rather than rebuilding the storage factory + credential bridge. This 
matters in the ordered
+    /// path, where the scan is one partition per file and `execute` is called 
once per file.
+    file_io: FileIO,
+    /// Table sort order Iceberg reported, translated against `output_schema`. 
`Some` makes this a
+    /// multi-partition scan: one sorted stream per task, which a 
SortPreservingMergeExec above
+    /// merges back into one sorted partition. It is also advertised in 
`plan_properties`. `None`
+    /// keeps the old single-partition unordered read (all tasks streamed 
together).
+    ///
+    /// Concurrency note: in the ordered path each partition reads exactly one 
task, so
+    /// `data_file_concurrency_limit` no longer bounds cross-file concurrency; 
instead the wrapping
+    /// SortPreservingMergeExec drives one reader per file to merge them. That 
fan-out (files per
+    /// Spark partition) is intrinsic to a k-way merge of per-file sorted 
streams -- the files must
+    /// be read as separate streams to stay individually sorted -- and is the 
natural granularity
+    /// for a sorted Iceberg table. `data_file_concurrency_limit` still bounds 
delete-file stats and

Review Comment:
   This is the paragraph @mbutrovich and I were arguing with, and the code has 
moved on. It still says the fan-out "is intrinsic to a k-way merge" and "is the 
natural granularity for a sorted Iceberg table", with no mention that the 
planner now caps it at `sortMerge.maxFilesPerPartition` and drops to a 
spillable `SortExec` above that. Could you point it at the cap, and at #5343 
for the bound-driven admission scheme? Otherwise the next person to read this 
concludes the fan-out is unbounded by design.



-- 
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]

Reply via email to