mbutrovich commented on code in PR #6725:
URL: https://github.com/apache/datafusion-comet/pull/6725#discussion_r4261120897


##########
spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala:
##########
@@ -774,15 +779,17 @@ case class CometScanRule(session: SparkSession)
         // fields to the table's by field id (apache/iceberg-rust#2617). A 
file written before a
         // nested field was added (to a struct, or to a struct inside a list 
or map) fails the
         // native scan with "Incorrect number of arrays for StructArray 
fields", and a nested field
-        // renamed since the file was written can read back as NULL. The 
native read asks for a
-        // projected column's full nested type even when Spark prunes it, 
taken from the current
-        // table schema, or from the scan schema when VERSION AS OF reads a 
dropped column (see
-        // CometIcebergNativeScan). A FileScanTask does not record which 
schema wrote its file, so
-        // fall back when any schema in the table's history lacks a nested 
field of a projected
-        // column or names it differently.
+        // renamed since the file was written can read back as NULL. The 
native read usually asks
+        // only for the nested fields Spark's schema pruning keeps, but a scan 
can still read with
+        // its full task schema (see IcebergReflection.nativeTaskSchemas), 
which for VERSION AS OF
+        // can be an older schema than the table's. So this check walks the 
table, scan, and full
+        // task schemas, and falls back even when the changed field is pruned 
away. A FileScanTask
+        // does not record which schema wrote its file, so fall back when any 
schema in the table's
+        // history lacks a nested field of a projected column or names it 
differently.
         val nestedFieldsSupported =
           try {
-            val schemas = Seq(metadata.tableSchema, metadata.scanSchema)
+            val schemas =
+              Seq(metadata.tableSchema, metadata.scanSchema) ++ 
metadata.fullTaskSchema

Review Comment:
   Should this check look at the schema the scan will actually read, rather 
than always adding `fullTaskSchema`? With pruning on, a scan whose 
`prunedTaskSchema` is defined never reads the full schema, but its full schema 
can still trigger the fallback.
   
   Take the unpartitioned table from my reply on the `nativeTaskSchemas` 
thread. With pruning on, `SELECT id, c, s.a FROM t VERSION AS OF S` reads the 
pruned schema, which holds only `s.a`. At 6213d0576e it ran natively and 
matched Spark, and it does on `main` too. At this commit it falls back with 
`s.b (added)` because the snapshot's full schema still has `s.b`. The 
suggestion below keeps the native read for that query. It still falls back for 
the partitioned case from the thread, where `prunedTaskSchema` is `None`, and 
for the unpartitioned case with pruning off.
   
   ```suggestion
               val readSchema = metadata.prunedTaskSchema
                 .filter(_ => 
CometConf.COMET_ICEBERG_NESTED_SCHEMA_PRUNING_ENABLED.get())
                 .orElse(metadata.fullTaskSchema)
               val schemas = Seq(metadata.tableSchema, metadata.scanSchema) ++ 
readSchema
   ```
   
   
[`CometIcebergNativeScan`](https://github.com/apache/datafusion-comet/blob/8566834663235f9cb47d2f5af3578ed46f57028b/spark/src/main/scala/org/apache/comet/serde/operator/CometIcebergNativeScan.scala#L1018-L1024)
 makes the same choice during serialization. What do you think about a method 
on `CometIcebergNativeScanMetadata` that both call, so the check and the read 
can't drift apart? The comment above would then describe the schema the scan 
reads instead of "the table, scan, and full task schemas". Could the new 
`evo_struct_historical` case also cover the unpartitioned table, to check that 
it stays native?



##########
spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala:
##########
@@ -1039,12 +1046,21 @@ case class CometScanRule(session: SparkSession)
           }
         }
 
+        // Every task needs each partition source and equality-delete key 
under one name (see
+        // IcebergReflection.nativeTaskSchemas).
+        val taskSchemaNamesSupported = metadata.fullTaskSchema.isDefined
+        if (!taskSchemaNamesSupported) {
+          fallbackReasons += "Comet's native Iceberg reader cannot name a 
partition source or " +
+            "equality-delete key the same way in every task: no schema in the 
table's history " +
+            "names it apart from the columns the scan reads"

Review Comment:
   This is a new fallback that users can hit. Should it go in the [Current 
limitations](https://github.com/apache/datafusion-comet/blob/474b5ece841a7fc6dff37ae04bd6787e654d7be7/docs/source/user-guide/latest/iceberg.md?plain=1#L177-L199)
 list in the Iceberg user guide, next to the nested schema evolution entry? 
Something like: "Scans where no schema in the table's history gives a partition 
source or equality-delete key a name that the scan's other columns don't use."



##########
spark/src/test/resources/sql-tests/iceberg/nested_schema_pruning.sql:
##########
@@ -0,0 +1,135 @@
+-- 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.
+
+-- The native Iceberg scan reads only the nested fields that Spark's nested 
schema pruning keeps.
+-- These queries check the pruned reads against Spark. CometIcebergNativeSuite 
checks that they
+-- scan fewer bytes, and covers deletes, time travel, and files without field 
ids.
+
+-- No Iceberg spark-runtime is published for Spark 4.2 yet, and the 4.0 
runtime the build reuses
+-- is binary-incompatible with it. See 
https://github.com/apache/datafusion-comet/issues/4969.
+-- MaxSparkVersion: 4.1
+
+-- Config: spark.sql.catalog.test_cat=org.apache.iceberg.spark.SparkCatalog
+-- Config: spark.sql.catalog.test_cat.type=hadoop
+-- Config: spark.sql.catalog.test_cat.warehouse=/tmp/comet-iceberg-sql-test
+-- Config: spark.comet.scan.icebergNative.enabled=true
+
+statement
+DROP TABLE IF EXISTS test_cat.db.nested_pruning
+
+statement
+CREATE TABLE test_cat.db.nested_pruning (
+  id INT,
+  s STRUCT<a: INT, pad: STRING, inner: STRUCT<b: INT, pad: STRING>>,
+  items ARRAY<STRUCT<x: INT, pad: STRING>>,
+  m MAP<STRING, STRUCT<v: INT, pad: STRING>>
+) USING iceberg
+
+-- NULL structs, lists, maps, list elements, map values, and fields, so that 
the pruned read has
+-- to rebuild the validity of every level from the leaves it keeps.
+statement
+INSERT INTO test_cat.db.nested_pruning
+SELECT
+  CAST(id AS INT),
+  IF(id % 7 = 0, NULL, named_struct(
+    'a', IF(id % 5 = 0, NULL, CAST(id AS INT)),
+    'pad', repeat('p', 20),
+    'inner', IF(id % 3 = 0, NULL, named_struct('b', CAST(id * 2 AS INT), 
'pad', repeat('q', 20))))),
+  IF(id % 7 = 0, NULL, array(
+    named_struct('x', CAST(id AS INT), 'pad', repeat('r', 20)),
+    IF(id % 5 = 0, NULL, named_struct('x', CAST(-id AS INT), 'pad', 
repeat('s', 20))))),
+  IF(id % 7 = 0, NULL, map('k', IF(id % 3 = 0, NULL,
+    named_struct('v', CAST(id AS INT), 'pad', repeat('t', 20)))))

Review Comment:
   The comment above says this data puts NULLs at every level so the pruned 
read has to rebuild validity. Should it also have empty lists and maps? An 
empty list is valid with no child rows, which is a different case from a NULL 
list or a NULL element. For example:
   
   ```suggestion
     CASE WHEN id % 7 = 0 THEN NULL WHEN id % 11 = 0 THEN array() ELSE array(
       named_struct('x', CAST(id AS INT), 'pad', repeat('r', 20)),
       IF(id % 5 = 0, NULL, named_struct('x', CAST(-id AS INT), 'pad', 
repeat('s', 20)))) END,
     CASE WHEN id % 7 = 0 THEN NULL WHEN id % 11 = 0 THEN map() ELSE map('k', 
IF(id % 3 = 0, NULL,
       named_struct('v', CAST(id AS INT), 'pad', repeat('t', 20)))) END
   ```



##########
spark/src/test/scala/org/apache/comet/CometIcebergNativeSuite.scala:
##########
@@ -2284,6 +2324,373 @@ class CometIcebergNativeSuite
     }
   }
 
+  // Spark's nested schema pruning reaches the native scan through the scan 
schema, so only the
+  // nested fields a query uses are read and the wide `pad` strings beside 
them are skipped.
+  // sql-tests/iceberg/nested_schema_pruning.sql checks the results. One data 
file holds every
+  // row, so each `pad` column chunk is larger than iceberg-rust's 1 MiB read 
coalescing, which
+  // would otherwise merge the reads of the kept chunks across the skipped 
ones.
+  test("nested schema pruning reads only the nested fields the query uses") {
+    assume(icebergAvailable, "Iceberg not available in classpath")
+
+    withTempIcebergDir { warehouseDir =>
+      withSQLConf(
+        "spark.sql.catalog.test_cat" -> 
"org.apache.iceberg.spark.SparkCatalog",
+        "spark.sql.catalog.test_cat.type" -> "hadoop",
+        "spark.sql.catalog.test_cat.warehouse" -> warehouseDir.getAbsolutePath,
+        CometConf.COMET_ENABLED.key -> "true",
+        CometConf.COMET_EXEC_ENABLED.key -> "true",
+        CometConf.COMET_ICEBERG_NATIVE_ENABLED.key -> "true") {
+
+        val table = "test_cat.db.nested_pruning"
+        spark.sql(s"""
+          CREATE TABLE $table (
+            id INT,
+            s STRUCT<a: INT, pad: STRING>,
+            items ARRAY<STRUCT<x: INT, pad: STRING>>,
+            m MAP<STRING, STRUCT<v: INT, pad: STRING>>
+          ) USING iceberg
+        """)
+        spark.sql(s"""
+          INSERT INTO $table
+          SELECT
+            CAST(id AS INT),
+            named_struct('a', CAST(id AS INT), 'pad', pad),
+            array(named_struct('x', CAST(id AS INT), 'pad', pad)),
+            map('k', named_struct('v', CAST(id AS INT), 'pad', pad))
+          FROM (
+            SELECT id, concat_ws('', transform(array('a', 'b', 'c', 'd'),
+              salt -> sha2(concat(CAST(id AS STRING), salt), 256))) AS pad
+            FROM range(0, 20000, 1, 1))
+        """)
+
+        def bytesScanned(pruneNestedFields: Boolean): Long = {
+          var bytes = 0L
+          withSQLConf(
+            CometConf.COMET_ICEBERG_NESTED_SCHEMA_PRUNING_ENABLED.key ->
+              pruneNestedFields.toString) {
+            val df = spark.sql(s"SELECT sum(s.a), count(items.x), 
count(m['k'].v) FROM $table")
+            df.collect()
+            val scans = 
collectIcebergNativeScans(df.queryExecution.executedPlan)
+            assert(scans.length == 1, s"expected one native scan, got 
${scans.length}")
+            bytes = scans.head.metrics("bytes_scanned").value
+          }
+          bytes
+        }
+        val prunedBytes = bytesScanned(pruneNestedFields = true)
+        val fullBytes = bytesScanned(pruneNestedFields = false)
+        assert(
+          prunedBytes * 4 < fullBytes,
+          s"pruned read should skip the pad fields: pruned=$prunedBytes, 
full=$fullBytes")
+
+        spark.sql(s"DROP TABLE $table")
+      }
+    }
+  }
+
+  // A pruned task schema still needs the columns iceberg-rust uses beyond the 
projection: the
+  // partition source and the equality-delete key when the query projects 
neither. Tasks with
+  // deletes read with the pruned schema too, and every task of a scan reads 
with the same one.
+  // VERSION AS OF reads of columns dropped, renamed, or swapped after the 
snapshot use the
+  // snapshot's names.
+  test("nested schema pruning with deletes, partitions, and time travel") {
+    assume(icebergAvailable, "Iceberg not available in classpath")
+
+    withTempIcebergDir { warehouseDir =>
+      withSQLConf(
+        "spark.sql.catalog.test_cat" -> 
"org.apache.iceberg.spark.SparkCatalog",
+        "spark.sql.catalog.test_cat.type" -> "hadoop",
+        "spark.sql.catalog.test_cat.warehouse" -> warehouseDir.getAbsolutePath,
+        CometConf.COMET_ENABLED.key -> "true",
+        CometConf.COMET_EXEC_ENABLED.key -> "true",
+        CometConf.COMET_ICEBERG_NATIVE_ENABLED.key -> "true") {
+
+        // Also checks that every task, tasks with deletes included, reads 
with one schema that
+        // leaves out the pruned `pad` fields.
+        def checkPrunedNativeScan(query: String): Unit = {

Review Comment:
   Could `checkPrunedNativeScan` also check the answer with 
`spark.comet.scan.icebergNative.nestedSchemaPruning.enabled` set to `false`? 
Could the queries in the equality-delete naming test do the same? With the 
config off, every task now reads `fullTaskSchema`, which uses the snapshot's 
names, so the renamed and swapped columns read in this test are the full-schema 
half of #6822. Right now nothing checks the config-off results for them. If 
these cases cover everything left in #6822 on `main`, should the PR description 
say it fixes #6822, leaving the `branch-1.1` part to a backport?



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