cloud-fan commented on code in PR #58895:
URL: https://github.com/apache/spark/pull/58895#discussion_r4109912730


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/DataSourceScanExec.scala:
##########
@@ -771,6 +836,28 @@ case class FileSourceScanExec(
     readRDD
   }
 
+  // Materialize scalar subqueries inside storage filters to literals and bind 
AttributeReferences
+  // to BoundReferences targeting positions in `requiredSchema`. Subqueries 
must have been prepared
+  // by SparkPlan before this is forced (same contract as `pushedDownFilters`).
+  @transient
+  protected lazy val preparedStorageFilters: Seq[Expression] = {
+    if (storageFilters.isEmpty) {
+      Nil
+    } else {
+      // No conf check here: the conf decides at planning time whether a scan 
is offered storage
+      // filters at all, and re-reading it now could only make this scan drop 
work it already has.
+      //
+      // `output` is `readDataColumns ++ generatedMetadataColumns ++ 
partitionColumns ++
+      // constantMetadataColumns` and `requiredSchema` is the StructType of 
the first two groups, so
+      // the first `requiredSchema.length` attributes line up with its fields.
+      val requestedDataAttrs = output.take(requiredSchema.length)
+      storageFilters.map { expr =>
+        val subqueryReplaced = expr.transform {  case s: 
execution.ScalarSubquery => s.toLiteral }
+        BindReferences.bindReference(subqueryReplaced, requestedDataAttrs)

Review Comment:
   **Non-blocking (P2):** `supportsStorageFilter` sees the original predicate, 
but this preparation replaces every `AttributeReference` with a 
`BoundReference` before `buildReaderWithStorageFilters` runs. That contradicts 
the documented same-expression handoff and means a format that opted in using a 
column name or field metadata cannot apply the same decision while constructing 
its reader. Could we carry both the original expression and an executable bound 
form (or otherwise preserve the advertised information)? Please also add an 
end-to-end generic `FileFormat` test that exercises the planner-to-reader 
handoff and the builder-returning-`None` fallback to 
`buildReaderWithPartitionValues`.
   
   **Recommended change:** Represent the original offered expression and its 
executable prepared form explicitly at the FileFormat handoff, so the builder 
can observe the promised original information while Parquet still evaluates a 
materialized, bound form. Add generic FileFormat integration coverage for 
identity/metadata preservation and for None falling back to the ordinary reader.
   
   **Why this works:** Prepare each accepted predicate without discarding its 
original representation, pass both representations through a single aligned 
handoff object or equivalent paired contract, and make Parquet consume the 
bound member. Exercise the full planning and scan-construction path with a test 
format that records the original expression and can decline specialized 
construction.
   
   **Scope:** Refine the generic V1 storage-filter contract, its 
planner-to-scan transport, the Parquet consumer adaptation, and regression 
coverage.
   
   **Compatibility:** Storage filtering remains a default-off optional 
optimization; unsupported formats and runtime give-up paths continue to return 
ordinary scan results.
   
   **Risks:** The two representations could become misaligned if they are 
stored as independent sequences rather than one paired value. Changing the 
newly added FileFormat method shape must keep default decline behavior and 
Parquet serialization intact.
   
   **Constraints:** Scalar subqueries must still be materialized before reader 
closure serialization. Parquet evaluation must remain bound to requiredSchema 
ordinals. The post-scan Filter remains authoritative and the optimization 
remains optional.
   
   **Success:** A format can correlate the exact named/metadata-bearing 
expression it accepted with the executable predicate used to construct its 
reader. Parquet receives a materialized expression bound to the requested 
schema and preserves current results and fallback behavior. Returning None from 
the specialized builder invokes the ordinary builder and returns its rows. A 
regression that canonicalizes or discards the original expression before reader 
construction fails the new integration coverage.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -1947,6 +1947,51 @@ object SQLConf {
       .checkValue(threshold => threshold >= 0, "The threshold must not be 
negative.")
       .createWithDefault(10)
 
+  val PARQUET_STORAGE_FILTER_PUSHDOWN_ENABLED =
+    buildConf("spark.sql.parquet.storageFilterPushdown.enabled")
+      .doc("If true, allows the vectorized Parquet reader to evaluate runtime 
storage filters " +
+        "(e.g. bloom filters from join runtime filtering) at the scan level 
using late " +
+        "materialization: read key columns first, evaluate the filter per row, 
then read data " +
+        "columns restricted to surviving rows. This is a planning-time 
decision only: when " +
+        "false, no storage filter is attached to a scan in the first place and 
the filter is " +
+        "applied as an ordinary post-scan filter alone. A pushed filter stays 
in the post-scan " +
+        "filter as well, the way a pushed data filter does, so honoring it is 
optional: a reader " +
+        "that meets a row group it cannot prune reads it the way a plain scan 
would, plus one " +
+        "more read of the key columns, since the phase that evaluated the 
filter already read " +
+        "them. A file written with no Parquet page index is read with the 
filter applied only " +
+        "where it empties a whole row group, since narrowing to part of one 
needs that index, so " +
+        "every row group of it that keeps a row pays that. Setting " +
+        "parquet.filter.columnindex.enabled to false turns this off entirely, 
because reading " +
+        "part of a row group goes through the page index. Note that " +
+        "the surviving key values of a whole row group are buffered before the 
" +
+        "first batch of that row group is produced, so a task holds up to one 
extra copy of the " +
+        "key columns for one row group.")
+      .version("5.0.0")
+      .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+      .booleanConf
+      .createWithDefault(false)
+
+  val PARQUET_STORAGE_FILTER_PUSHDOWN_MAX_SPLICED_ROW_GROUP_BYTES =
+    
buildConf("spark.sql.parquet.storageFilterPushdown.maxSplicedRowGroupBytes")
+      .internal()
+      .doc("Most memory, in bytes, that the vectorized Parquet reader holds 
for one row group " +

Review Comment:
   **Nit (P3):** The opening `Most memory ...` is a sentence fragment, and the 
following independent clauses are joined with commas. Since this text appears 
in the generated configuration reference, could you rewrite it as a complete 
definition (for example, `The maximum memory ...`) and split the accounting 
rules into complete sentences?



##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/ParquetReadState.java:
##########
@@ -19,30 +19,29 @@
 
 import org.apache.parquet.column.ColumnDescriptor;
 
-import java.util.ArrayList;
-import java.util.Iterator;
-import java.util.List;
 import java.util.PrimitiveIterator;
 
 /**
  * Helper class to store intermediate state while reading a Parquet column 
chunk.
  */
 final class ParquetReadState {
-  /** A special row range used when there is no row indexes (hence all rows 
must be included) */
-  private static final RowRange MAX_ROW_RANGE = new RowRange(Long.MIN_VALUE, 
Long.MAX_VALUE);
+  /** The row indexes to include, only not-null if the column index is 
present. */

Review Comment:
   **Nit (P3):** Non-null row indexes are not conditioned solely on a column 
index. The late-materialization path can pass `finalRanges` to 
`readFilteredRowGroup`, which derives row indexes from offset indexes even when 
the optional column index is absent. Could this comment describe the actual 
condition under which `rowIndexes` is populated?



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetStorageFilter.scala:
##########
@@ -0,0 +1,279 @@
+/*
+ * 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.
+ */
+
+package org.apache.spark.sql.execution.datasources.parquet
+
+import java.util.concurrent.ConcurrentHashMap
+
+import org.apache.spark.SparkThrowable
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.{And, BasePredicate, 
BloomFilterMightContain, BoundReference, Expression, Literal, Predicate, 
XxHash64}
+import org.apache.spark.sql.catalyst.trees.TreePattern.PLAN_EXPRESSION
+import org.apache.spark.sql.execution.metric.SQLMetric
+import org.apache.spark.sql.types.{BinaryType, BooleanType, ByteType, 
DataType, DateType, DayTimeIntervalType, DecimalType, DoubleType, FloatType, 
IntegerType, LongType, ShortType, StringType, StructType, TimestampNTZType, 
TimestampType, TimeType, YearMonthIntervalType}
+
+/**
+ * Optional SQL metrics the reader updates while applying a 
[[ParquetStorageFilter]]. Every
+ * counter is scoped to what the storage filter added on top of a read of the 
same projection
+ * without one. All fields are nullable; a null field disables that metric.
+ *
+ *  - [[rowGroupsSkipped]] counts row groups whose data columns were never 
read.
+ *  - [[rowsExcludedByRowGroup]] sums the rows those skips excluded, per 
skipped block the rows that
+ *    survived the pushed data filter.
+ *  - [[rowsExcludedWithinRowGroup]] sums rows excluded inside row groups that 
were kept.
+ *  - [[bytesAvoidedByRowGroup]] sums, per skipped row group, the non-key 
bytes a plain read of this
+ *    projection would have transferred for the rows that survived the pushed 
data filter. Phase 1
+ *    reads the key columns of every block, so key bytes are never part of it, 
and it is zero on an
+ *    all-keys projection, which can avoid nothing.
+ *  - [[bytesAvoidedByPageFiltering]] sums, per kept row group, that same 
non-key baseline minus the
+ *    bytes phase 2 read, which is what `finalRanges` page selection pruned.
+ *
+ * The row counters' suffix says where a row was excluded, not what would have 
saved it: a row
+ * inside a kept row group is read as part of its page and dropped during 
decode, so page

Review Comment:
   **Nit (P3):** `rowsExcludedWithinRowGroup` counts all rows rejected inside a 
retained row group, but it does not prove that every rejected row's page was 
read: phase 2 can skip a page containing no surviving rows. Could this describe 
only what the counter establishes, without the row/page IO claim? The adjacent 
`rowsExcludedByRowGroup` bullet also needs a complete sentence.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetStorageFilterSuite.scala:
##########
@@ -0,0 +1,2526 @@
+/*
+ * 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.
+ */
+
+package org.apache.spark.sql.execution.datasources.parquet
+
+import java.io.{ByteArrayOutputStream, File}
+import java.net.URI
+import java.time.LocalTime
+import java.util.concurrent.atomic.AtomicLong
+
+import scala.collection.mutable
+import scala.jdk.CollectionConverters._
+
+import org.apache.hadoop.conf.Configuration
+import org.apache.hadoop.fs.{FileStatus, FSDataInputStream, FSInputStream, 
Path, RawLocalFileSystem}
+import org.apache.hadoop.mapreduce.Job
+import org.apache.parquet.column.{Encoding, ParquetProperties}
+import org.apache.parquet.column.impl.ColumnWriteStoreV1
+import org.apache.parquet.column.page.DataPageV1
+import org.apache.parquet.column.page.mem.MemPageStore
+import org.apache.parquet.hadoop.{ParquetFileReader, ParquetFileWriter, 
ParquetInputFormat, ParquetOutputFormat}
+import org.apache.parquet.hadoop.metadata.{ColumnChunkMetaData, 
CompressionCodecName}
+import org.apache.parquet.hadoop.util.HadoopOutputFile
+import org.apache.parquet.schema.MessageTypeParser
+
+import org.apache.spark.paths.SparkPath
+import org.apache.spark.sql.{sources, DataFrame, QueryTest, Row, SparkSession}
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.{And, Attribute, 
AttributeReference, BloomFilterMightContain, BoundReference, Cast, Coalesce, 
EqualTo, Expression, GreaterThanOrEqual, IsNull, LessThanOrEqual, Literal, Or, 
Predicate, Rand, Remainder, XxHash64}
+import org.apache.spark.sql.catalyst.plans.logical.{Filter => LogicalFilter}
+import org.apache.spark.sql.execution.{CollapseCodegenStages, 
ColumnarToRowExec, FileSourceScanExec, FileSourceScanLike, FilterExec, 
LocalLimitExec, SparkPlan, WholeStageCodegenExec}
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.execution.datasources.{FileFormat, 
FileSourceStrategy, OutputWriterFactory, PartitionedFile}
+import org.apache.spark.sql.execution.metric.{SQLMetric, SQLMetrics}
+import org.apache.spark.sql.functions.col
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types._
+import org.apache.spark.sql.vectorized.{ColumnarBatch, ColumnVector}
+import org.apache.spark.unsafe.types.UTF8String
+import org.apache.spark.util.Utils
+import org.apache.spark.util.sketch.BloomFilter
+
+/**
+ * Tests the late-materialization path of [[VectorizedParquetRecordReader]] 
driven by a
+ * [[ParquetStorageFilter]]. Writes small multi-row-group parquet files, wires 
a hand-built filter
+ * into the reader, and asserts correctness + the two storage-filter metrics.
+ */
+class ParquetStorageFilterSuite extends QueryTest with SharedSparkSession
+  with AdaptiveSparkPlanHelper {
+  import testImplicits._
+
+  // Writes `df` as one parquet file under a fresh directory and returns its 
path. Every write
+  // helper in this suite goes through here.
+  private def writeSingleParquetFile(
+      dir: File,
+      df: DataFrame,
+      rowGroupSize: Long,
+      pageSize: Option[Long] = None,
+      dictionary: Boolean = false): String = {
+    val outDir = new File(dir, s"test-${System.nanoTime()}").getAbsolutePath
+    val writer = df
+      .repartition(1)
+      .write
+      .option(ParquetOutputFormat.BLOCK_SIZE, rowGroupSize)
+      // Dictionary encoding off keeps row-group sizing predictable. The 
column index is still
+      // written either way.
+      .option(ParquetOutputFormat.ENABLE_DICTIONARY, dictionary.toString)
+    // A small page size gives each row group several pages per column, which 
is what lets
+    // column-index filtering produce a row range narrower than the whole row 
group.
+    pageSize.foreach(size => writer.option(ParquetOutputFormat.PAGE_SIZE, 
size))
+    writer.parquet(outDir)
+    val files = new File(outDir).listFiles((_, name) => 
name.endsWith(".parquet"))
+    assert(files != null && files.length == 1, s"expected exactly one parquet 
file under $outDir")
+    files(0).getAbsolutePath
+  }
+
+  // Writes a parquet file with the given rows and row-group size; returns the 
path.
+  private def writeParquetFile(
+      dir: File,
+      rows: Seq[(Long, String)],
+      rowGroupSize: Long = 1024L,
+      pageSize: Option[Long] = None): String =
+    writeSingleParquetFile(dir, rows.toDF("k", "v"), rowGroupSize, pageSize)
+
+  // Collects all `(k, v)` rows from a reader initialized with the given 
storage filter.
+  private def readAll(
+      filePath: String,
+      storageFilter: ParquetStorageFilter): (Seq[(Long, String)], 
VectorizedParquetRecordReader) =
+    readAllWith(filePath, Seq("k", "v"), storageFilter,
+      (batch, i) => (batch.column(0).getLong(i), 
batch.column(1).getUTF8String(i).toString))
+
+  // Builds a `k >= threshold` storage filter bound to position 0.
+  private def keyAtLeastFilter(
+      threshold: Long,
+      metrics: StorageFilterMetrics = StorageFilterMetrics()): 
ParquetStorageFilter = {
+    val expr = GreaterThanOrEqual(BoundReference(0, LongType, nullable = 
false), Literal(threshold))
+    val requested = StructType(Seq(
+      StructField("k", LongType, nullable = false), StructField("v", 
StringType, nullable = false)))
+    ParquetStorageFilter.create(Seq(expr), requested, metrics)
+  }
+
+  // Writes a single-column (just `k`) parquet file for the supplied key type 
via Spark's
+  // {@code Encoder}.
+  private def writeKeyOnlyParquetFile[T : org.apache.spark.sql.Encoder](
+      dir: File,
+      keys: Seq[T],
+      rowGroupSize: Long = 1024L): String =
+    writeSingleParquetFile(dir, spark.createDataset(keys).toDF("k"), 
rowGroupSize)
+
+  // Reads a key-only file, returning the survivor keys and the reader. The 
{@code extract} function
+  // pulls one value at a time from the batch's key column.
+  private def readKeyOnlyAll[T](
+      filePath: String,
+      storageFilter: ParquetStorageFilter,
+      extract: (org.apache.spark.sql.vectorized.ColumnVector, Int) => T,
+      capacity: Int = 4096): (Seq[T], VectorizedParquetRecordReader) =
+    readAllWith(filePath, Seq("k"), storageFilter, (batch, i) => 
extract(batch.column(0), i),
+      capacity)
+
+  // Builds a `k >= threshold` storage filter bound to position 0 against a 
key-only schema of the
+  // given key type.
+  private def keyOnlyAtLeastFilter(
+      threshold: Literal,
+      keyType: DataType,
+      metrics: StorageFilterMetrics = StorageFilterMetrics()): 
ParquetStorageFilter = {
+    val expr = GreaterThanOrEqual(BoundReference(0, keyType, nullable = 
false), threshold)
+    val requested = StructType(Seq(StructField("k", keyType, nullable = 
false)))
+    ParquetStorageFilter.create(Seq(expr), requested, metrics)
+  }
+
+  test("rejects entire row group: no data-column IO, row-group-skipped metric 
incremented") {
+    withTempDir { dir =>
+      // 40 rows in one row group: parquet's first row-group size check is at 
record 100, so
+      // `rowGroupSize` cannot split a file this small. The one row group is 
the one rejected.
+      val rows = (1L to 40L).map(i => (i, s"v_$i"))
+      val path = writeParquetFile(dir, rows, rowGroupSize = 256L)
+
+      val rgSkipped = SQLMetrics.createMetric(spark.sparkContext, 
"rowGroupsSkipped")
+      val rowsExcludedRg = SQLMetrics.createMetric(spark.sparkContext, 
"rowsExcludedByRowGroup")
+      val rowsExcludedPf = SQLMetrics.createMetric(spark.sparkContext, 
"rowsExcludedWithinRowGroup")
+      val bytesAvoidedRg = SQLMetrics.createSizeMetric(spark.sparkContext, 
"bytesAvoidedByRg")
+      val bytesAvoidedPf = SQLMetrics.createSizeMetric(spark.sparkContext, 
"bytesAvoidedByPf")
+      val filter = keyAtLeastFilter(1000L, StorageFilterMetrics(
+        rowGroupsSkipped = rgSkipped,
+        rowsExcludedByRowGroup = rowsExcludedRg,
+        rowsExcludedWithinRowGroup = rowsExcludedPf,
+        bytesAvoidedByRowGroup = bytesAvoidedRg,
+        bytesAvoidedByPageFiltering = bytesAvoidedPf))
+      val (result, reader) = readAll(path, filter)
+      try {
+        assert(result.isEmpty, "filter rejects all rows; no rows should be 
emitted")
+        assert(rgSkipped.value > 0,
+          s"expected at least one row group skipped; got ${rgSkipped.value}")
+        assert(rowsExcludedRg.value > 0,
+          s"expected rows excluded by whole-rowgroup skip; got 
${rowsExcludedRg.value}")
+        assert(rowsExcludedPf.value == 0,
+          s"no partial-row-group filtering expected; got 
${rowsExcludedPf.value}")
+        // Schema is (k: Long, v: String). Skipping a row group avoids the 
v-column bytes the
+        // no-storage-filter path would have read; phase 1 still pays for k. 
So avoided > 0.
+        assert(bytesAvoidedRg.value > 0,
+          s"expected non-key bytes avoided by whole row groups; got 
${bytesAvoidedRg.value}")
+        assert(bytesAvoidedPf.value == 0,
+          s"no page-filtering bytes expected when all groups skipped; got 
${bytesAvoidedPf.value}")
+      } finally {
+        reader.close()
+      }
+    }
+  }
+
+  test("all rows survive: no skipping and no filtering") {
+    withTempDir { dir =>
+      val rows = (1L to 40L).map(i => (i, s"v_$i"))
+      val path = writeParquetFile(dir, rows, rowGroupSize = 256L)
+
+      val rgSkipped = SQLMetrics.createMetric(spark.sparkContext, 
"rowGroupsSkipped")
+      val rowsExcludedPf = SQLMetrics.createMetric(spark.sparkContext, 
"rowsExcludedWithinRowGroup")
+      val filter = keyAtLeastFilter(0L, StorageFilterMetrics(
+        rowGroupsSkipped = rgSkipped, rowsExcludedWithinRowGroup = 
rowsExcludedPf))
+      val (result, reader) = readAll(path, filter)
+      try {
+        assert(result.toSet == rows.toSet, s"all rows should round-trip; got 
${result.size} rows")
+        assert(rgSkipped.value == 0, s"nothing should be skipped; got 
${rgSkipped.value}")
+        assert(rowsExcludedPf.value == 0,
+          s"nothing should be filtered; got ${rowsExcludedPf.value}")
+      } finally {
+        reader.close()
+      }
+    }
+  }
+
+  test("mixed: some row groups skipped, others partially kept") {
+    withTempDir { dir =>
+      // Many rows + small row groups => guaranteed multiple row groups.
+      val rows = (1L to 200L).map(i => (i, s"v_$i"))
+      val path = writeParquetFile(dir, rows, rowGroupSize = 256L)
+
+      val rgSkipped = SQLMetrics.createMetric(spark.sparkContext, 
"rowGroupsSkipped")
+      val rowsExcludedRg = SQLMetrics.createMetric(spark.sparkContext, 
"rowsExcludedByRowGroup")
+      val rowsExcludedPf = SQLMetrics.createMetric(spark.sparkContext, 
"rowsExcludedWithinRowGroup")
+      // k >= 195 keeps only the last 6 rows; earlier row groups should be 
skipped.
+      val filter = keyAtLeastFilter(195L, StorageFilterMetrics(
+        rowGroupsSkipped = rgSkipped,
+        rowsExcludedByRowGroup = rowsExcludedRg,
+        rowsExcludedWithinRowGroup = rowsExcludedPf))
+      val (result, reader) = readAll(path, filter)
+      try {
+        // Output is exact: VectorizedColumnReader uses 
PageReadStore.getRowIndexes (driven by
+        // our finalRanges) to skip rows within partial pages, so emitted rows 
== survivors.
+        val expected = rows.filter(_._1 >= 195L).toSet
+        assert(result.toSet == expected,
+          s"expected exact filtering; got ${result.map(_._1).sorted}, " +
+            s"expected ${expected.map(_._1).toSeq.sorted}")
+        assert(rgSkipped.value >= 1, s"expected row groups skipped; got 
${rgSkipped.value}")
+        // "Partially kept" is the `rowsExcludedWithinRowGroup` half of the 
accounting, and only
+        // this identity establishes it: a row group that was neither skipped 
whole nor emitted has
+        // to have its rows counted there.
+        assert(rowsExcludedPf.value > 0,
+          s"expected rows excluded inside a kept row group; got 
${rowsExcludedPf.value}")
+        assert(result.size + rowsExcludedRg.value + rowsExcludedPf.value == 
rows.size,
+          s"${result.size} emitted plus ${rowsExcludedRg.value} plus 
${rowsExcludedPf.value} " +
+            s"should account for all ${rows.size} rows")
+      } finally {
+        reader.close()
+      }
+    }
+  }
+
+  test("key-only projection: phase 2 is skipped and both byte-avoided metrics 
are zero") {
+    // When the projected schema contains only the bloom key, phase 2 is 
skipped entirely
+    // (`nonKeyColumns == null` in the reader). All output rows come from the 
per-key-column
+    // queues populated in phase 1. Total bytes read match the 
no-storage-filter path (phase 1 reads
+    // the key column once instead of phase 2 re-reading it), so both 
`avoided` metrics are zero:
+    // there are no non-key bytes to skip. This is the shape the design notes 
call the biggest win,
+    // so it must not be the shape that pays for metrics.
+    withTempDir { dir =>
+      val keys = (1L to 200L)
+      val path = writeKeyOnlyParquetFile(dir, keys, rowGroupSize = 256L)
+
+      val rgSkipped = SQLMetrics.createMetric(spark.sparkContext, 
"rowGroupsSkipped")
+      val rowsExcludedRg = SQLMetrics.createMetric(spark.sparkContext, 
"rowsExcludedByRowGroup")
+      val rowsExcludedPf = SQLMetrics.createMetric(spark.sparkContext, 
"rowsExcludedWithinRowGroup")
+      val bytesAvoidedRg = SQLMetrics.createSizeMetric(spark.sparkContext, 
"bytesAvoidedByRg")
+      val bytesAvoidedPf = SQLMetrics.createSizeMetric(spark.sparkContext, 
"bytesAvoidedByPf")
+      // k >= 195 -> last 6 keys survive; preceding row groups skipped or 
page-pruned.
+      val filter = keyOnlyAtLeastFilter(Literal(195L), LongType, 
StorageFilterMetrics(
+        rowGroupsSkipped = rgSkipped,
+        rowsExcludedByRowGroup = rowsExcludedRg,
+        rowsExcludedWithinRowGroup = rowsExcludedPf,
+        bytesAvoidedByRowGroup = bytesAvoidedRg,
+        bytesAvoidedByPageFiltering = bytesAvoidedPf))
+      val (result, reader) = readKeyOnlyAll(path, filter, (vec, i) => 
vec.getLong(i))
+      try {
+        val expected = keys.filter(_ >= 195L).toSet
+        assert(result.toSet == expected,
+          s"expected exact survivor keys; got ${result.sorted}, expected 
${expected.toSeq.sorted}")
+        assert(rgSkipped.value >= 1, s"expected row groups skipped; got 
${rgSkipped.value}")
+        assert(rowsExcludedRg.value > 0,
+          s"a skipped row group must count its rows too; got 
${rowsExcludedRg.value}")
+        // The all-keys path takes its kept-row count from `finalRowCount` 
rather than from a
+        // phase-2 page store, so this identity is the only thing that checks 
that arithmetic.
+        assert(result.size + rowsExcludedRg.value + rowsExcludedPf.value == 
keys.size,
+          s"${result.size} emitted plus ${rowsExcludedRg.value} plus 
${rowsExcludedPf.value} " +
+            s"should account for all ${keys.size} rows")
+        // For an all-keys projection, the no-storage-filter path would have 
read the same key
+        // column phase 1 reads. There are no non-key bytes to avoid; both 
metrics are 0.
+        assert(bytesAvoidedRg.value == 0,
+          s"expected no non-key bytes to avoid on all-keys projection; got 
${bytesAvoidedRg.value}")
+        assert(bytesAvoidedPf.value == 0,
+          s"expected no non-key bytes to avoid on all-keys projection; got 
${bytesAvoidedPf.value}")
+      } finally {
+        reader.close()
+      }
+    }
+  }
+
+  test("multi-batch emit: survivor count exceeds capacity") {
+    // Drive the reader at capacity = 16 with a row group of 100 surviving 
rows. Exercises:
+    //   - The per-key-column queue holding multiple full-capacity vectors 
plus a partial tail.
+    //   - The published queue head getting closed at the start of every 
subsequent emit.
+    //   - The batch's key slots being rewritten ceil(100/16) = 7 times.
+    withTempDir { dir =>
+      val keys = (1L to 100L)
+      // Big rowGroupSize so all 100 rows fit in one row group.
+      val path = writeKeyOnlyParquetFile(dir, keys, rowGroupSize = 64 * 1024L)
+      // Filter accepts every row so the queue is fully populated.
+      val filter = keyOnlyAtLeastFilter(Literal(0L), LongType)
+      val (result, reader) =
+        readKeyOnlyAll(path, filter, (vec, i) => vec.getLong(i), capacity = 16)
+      try {
+        assert(result == keys.toSeq,
+          s"expected all keys returned in order across multiple batches; got 
${result.size} rows")
+      } finally {
+        reader.close()
+      }
+    }
+  }
+
+  test("int key column: filter survivors round-trip through phase 1 
accumulators") {
+    // Covers the IntegerType branch of ValueCopier.
+    withTempDir { dir =>
+      val keys = (1 to 100)
+      val path = writeKeyOnlyParquetFile(dir, keys, rowGroupSize = 256L)
+      val filter = keyOnlyAtLeastFilter(Literal(90), IntegerType)
+      val (result, reader) = readKeyOnlyAll(path, filter, (vec, i) => 
vec.getInt(i))
+      try {
+        assert(result.toSet == keys.filter(_ >= 90).toSet,
+          s"expected int keys >= 90; got ${result.sorted}")
+      } finally {
+        reader.close()
+      }
+    }
+  }
+
+  test("string key column: filter survivors round-trip through phase 1 
accumulators") {
+    // Covers the StringType branch of ValueCopier (variable-length byte copy 
via putByteArray).
+    withTempDir { dir =>
+      val keys = (1 to 20).map(i => f"k$i%03d")
+      val path = writeKeyOnlyParquetFile(dir, keys, rowGroupSize = 256L)
+      val filter = keyOnlyAtLeastFilter(
+        Literal.create("k015", StringType), StringType)
+      val (result, reader) =
+        readKeyOnlyAll(path, filter, (vec, i) => vec.getUTF8String(i).toString)
+      try {
+        assert(result.toSet == keys.filter(_ >= "k015").toSet,
+          s"expected string keys >= 'k015'; got ${result.sorted}")
+      } finally {
+        reader.close()
+      }
+    }
+  }
+
+  test("ParquetStorageFilter.create rejects a filter that violates a planner 
precondition") {
+    // These are all planner bugs by construction: storageFiltersFor 
pre-checks each one, and
+    // by the time create runs the conjunct is gone from the post-scan Filter, 
so a soft rejection

Review Comment:
   **Nit (P3):** Two comments in this suite describe a different contract from 
the one being tested: the suite exercises five storage-filter metrics rather 
than two, and `FileSourceStrategy` deliberately retains this conjunct in the 
post-scan `Filter` while also attaching it to the scan. Could you update both 
comments so they document the actual coverage and fallback-safety mechanism?



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