dongjoon-hyun commented on code in PR #59269:
URL: https://github.com/apache/spark/pull/59269#discussion_r4212446466


##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/LateMaterializationParquetRecordReader.java:
##########
@@ -604,12 +655,14 @@ protected boolean loadNextRowGroup() throws IOException {
   }
 
   /**
-   * A spliced row group's key slots take their values from the survivor queue 
at emit, so phase 2
-   * reads no pages for them. Key columns are primitive, which is what the 
base class asks of a slot
-   * answered true here.
+   * The checked slot always holds this reader's own vector, so a file column 
of that name, which a
+   * case-insensitive or user-given schema can match, is never decoded into 
the batch. A spliced row
+   * group's key slots take their values from the survivor queue at emit, so 
phase 2 reads no pages
+   * for them. Both are primitive, which is what the base class asks of a slot 
answered true here.
    */
   @Override
   protected boolean suppliesOwnVector(int slot) {
+    if (slot == checkedSlot) return true;

Review Comment:
   **1. A nested file column with the reserved name still fails the read**
   
   The reply says a file holding this column outside the relation's schema is 
handled now. From the code, that holds only when the column is primitive. A 
group column of that name fails in `initialize`, before `suppliesOwnVector` is 
asked:
   
   - `ParquetReadSupport.clipParquetTypeDefault` returns the file's group 
unchanged for the Spark `BooleanType` (`case _ => parquetType`).
   - `ParquetToSparkSchemaConverter.convertGroupField` then runs 
`convertInternal(groupColumn, sparkReadType.map(_.asInstanceOf[StructType]))` 
on `BooleanType`, which throws a `ClassCastException`.
   
   Two files would hit this when the table's schema comes from the catalog or 
the user and leaves the column out:
   
   - one holding `_tmp_storage_filter_checked struct<a:int>`, or the upper-case 
name under case-insensitive resolution;
   - one holding both `_tmp_storage_filter_checked` and 
`_TMP_STORAGE_FILTER_CHECKED`, which fails case-insensitive clipping with the 
duplicate-field error.
   
   A query over such a file worked before this PR, since nothing asked for the 
column. The new test covers an INT64 column and a boolean one, but no nested 
one.
   
   Could the checked field be left out of the schema handed to clipping and 
conversion, with the reader adding the slot itself? That would cover every 
type, as well as the wasted IO in comment 4. Otherwise, please add a nested 
case to the test and narrow the claim in the javadoc above.



##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/LateMaterializationParquetRecordReader.java:
##########
@@ -436,9 +481,13 @@ private void initializeLateMaterialization() throws 
IOException {
     // a file missing every projected non-key leaf, which also covers the 
row-index column. A struct
     // the file has, but none of whose requested fields it has, counts only 
under the legacy
     // `returnNullStructIfAllFieldsMissing`, since otherwise the clipped 
schema reads one of its
-    // other fields to tell a null struct from one whose requested fields are 
all null.
+    // other fields to tell a null struct from one whose requested fields are 
all null. The checked
+    // column is left out too, since a file column of its name is never read.
+    String checkedName =
+      checkedSlot >= 0 ? sparkRequestedSchema.fields()[checkedSlot].name() : 
null;
     List<ColumnDescriptor> nonKey = requestedColumns.stream()
         .filter(column -> !keyTopLevelNames.contains(column.getPath()[0]))
+        .filter(column -> !column.getPath()[0].equalsIgnoreCase(checkedName))

Review Comment:
   **4. The file's column of that name is still read from storage**
   
   This leaves the file's column of the checked name out of `nonKeyColumns`, 
but `requestedColumns` and `requestedPaths` still hold it. So:
   
   - phase 2 reads its chunk whenever splicing or the filter is given up, 
through `setRequestedSchema(requestedColumns)`;
   - `super.loadNextRowGroup()` reads it for a declined file;
   - `hasOffsetIndexes(..., requestedPaths)` and the byte metrics count it.
   
   Nothing ever decodes it, so a wide hidden column (a large binary, say) costs 
IO for nothing. Leaving it out of `requestedColumns` as well, or out of the 
clipped schema as in comment 1, would avoid that.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileSourceStrategy.scala:
##########
@@ -350,23 +357,34 @@ object FileSourceStrategy extends Strategy with 
PredicateHelper with Logging {
         }
       }
 
-      val outputDataSchema = (readDataColumns ++ 
generatedMetadataColumns).toStructType
-
       // Offered conjuncts become `storageFilters` on the scan and stay in the 
post-scan Filter too.
-      // This runs here because eligibility depends on `outputDataSchema`.
-      val storageFilters =
-        storageFiltersFor(normalizedFilters, fsRelation, readDataColumns, 
outputDataSchema)
+      // This runs here because eligibility depends on the data columns the 
reader reads.
+      val readerDataColumns = readDataColumns ++ generatedMetadataColumns
+      val storageFilters = storageFiltersFor(
+        normalizedFilters, fsRelation, readDataColumns, 
readerDataColumns.toStructType)
+      // The column the reader marks the rows it checked in, see
+      // `FileFormat.buildReaderWithStorageFilters`. It is added only where 
the scan still returns
+      // columnar batches into whole-stage codegen with it, which fuses the 
Filter and the Project
+      // that drops the column. Elsewhere that Project would copy every 
surviving row.
+      val storageFilterCheckedColumn = Option.when(storageFilters.nonEmpty) {
+        AttributeReference(FileFormat.STORAGE_FILTER_CHECKED_COLUMN_NAME, 
BooleanType)()
+      }.filter { checked =>
+        val conf = fsRelation.sparkSession.sessionState.conf
+        val output = readerDataColumns ++ partitionColumns ++ 
constantMetadataColumns :+ checked
+        conf.wholeStageEnabled && !WholeStageCodegenExec.isTooManyFields(conf, 
output.toStructType)

Review Comment:
   **2. The gate does not see a `Filter` that whole-stage codegen refuses**
   
   The comment above says the column is added only where the `Filter` and the 
`Project` that drops it are fused into whole-stage codegen. 
`CollapseCodegenStages.supportCodegen` also refuses an operator whose 
expressions contain a `CodegenFallback` (`willFallback`), and this condition 
does not check for that.
   
   For example, `SELECT * FROM fact JOIN dim ON ... WHERE exists(fact.tags, t 
-> t = 'x')` with an injected runtime bloom. Higher-order functions and 
`reflect` are `CodegenFallback`, so:
   
   - the `FilterExec` runs interpreted, outside the stage;
   - the scan still gets the checked column;
   - the `ProjectExec` that exists only to drop it gets a codegen stage of its 
own over an `InputAdapter`, and copies every surviving row through an 
`UnsafeProjection`.
   
   That is the cost the gate is meant to avoid. A runtime whole-stage fallback 
(`hugeMethodLimit`, a compile failure) ends the same way, though that one 
cannot be known at planning time.
   
   Could the condition also require that no conjunct of `afterScanFilters` 
contains a `CodegenFallback`? A case in the gate test would cover it.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetStorageFilter.scala:
##########
@@ -203,6 +204,7 @@ object ParquetStorageFilter {
         s"${ParquetFileFormat.ROW_INDEX_TEMPORARY_COLUMN_NAME}, because the 
reader writes row " +
         "indexes over that column, so what it reads back depends on how its 
row group was read")
 
+

Review Comment:
   **3. The guard that kept a key off the checked slot was dropped**
   
   The removed `require(!originalOrdinals.contains(checkedColumnIndex))` is not 
re-established anywhere, while the reader's class javadoc still says that what 
`ParquetStorageFilter.create` guarantees is asserted there.
   
   If a planner bug, or a later producer of storage filters, ever binds a key 
to that slot, nothing fails fast any more:
   
   - `suppliesOwnVector(checkedSlot)` answers true before the key check, so a 
row group that is not spliced never decodes that key;
   - the emit loop sets `spliceBatchColumns[slot]` to the persistent or 
survivor vector, which replaces `checkedVector` in the batch, while 
`putBooleans` keeps writing to the detached one.
   
   The rows would then carry stale key values and no marks, where they used to 
get an internal error. Could the check move into the reader, for example in 
`initializeLateMaterialization` against `keyIndices`?
   
   Nit: removing the block also left two blank lines here.



##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/LateMaterializationParquetRecordReader.java:
##########
@@ -436,9 +481,13 @@ private void initializeLateMaterialization() throws 
IOException {
     // a file missing every projected non-key leaf, which also covers the 
row-index column. A struct
     // the file has, but none of whose requested fields it has, counts only 
under the legacy
     // `returnNullStructIfAllFieldsMissing`, since otherwise the clipped 
schema reads one of its
-    // other fields to tell a null struct from one whose requested fields are 
all null.
+    // other fields to tell a null struct from one whose requested fields are 
all null. The checked
+    // column is left out too, since a file column of its name is never read.
+    String checkedName =
+      checkedSlot >= 0 ? sparkRequestedSchema.fields()[checkedSlot].name() : 
null;

Review Comment:
   **6. Nit: `checkedName` is always the constant**
   
   `checkedColumnIndex` finds the slot by an exact `indexOf` of 
`FileFormat.STORAGE_FILTER_CHECKED_COLUMN_NAME`, so reading the name back from 
`sparkRequestedSchema` always gives that constant. Using the constant, guarded 
by `checkedSlot >= 0`, would make it clear the name cannot vary.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -1982,16 +1982,22 @@ object SQLConf {
         "only for the rows that survived. This is a planning-time decision. " +
         "A filter that is attached also stays in the post-scan filter, the way 
a pushed data " +
         "filter does, so the reader is free to stop applying it wherever doing 
so would cost " +
-        "more than it saves, and the answer does not change. A row group where 
it stops reads " +
+        "more than it saves, and the answer does not change. In a scan that 
returns columnar " +
+        "batches into whole-stage codegen, the post-scan filter skips it on 
the rows the reader " +
+        "applied it to, which the reader marks in a column named 
_tmp_storage_filter_checked. " +
+        "A table with a column of that name is not offered storage filters. A 
row group where " +
+        "it stops reads " +

Review Comment:
   **5. "it" lost its antecedent**
   
   "A row group where it stops reads its key columns twice" used to follow "the 
reader is free to stop applying it". With the new sentence in between, "it" now 
reads as the table, or as its column. How about:
   
   ```suggestion
           "A table with a column of that name is not offered storage filters. 
A row group where " +
           "the reader stops applying the filter reads " +
   ```



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