jordepic commented on code in PR #5724:
URL: https://github.com/apache/datafusion-comet/pull/5724#discussion_r3968871038


##########
native/core/src/execution/operators/iceberg_write.rs:
##########
@@ -665,8 +666,146 @@ fn build_writer_properties(settings: 
&IcebergParquetWriteSettings) -> DFResult<W
         .set_dictionary_page_size_limit(settings.dict_size_bytes as usize)
         .set_data_page_row_count_limit(settings.page_row_limit as usize)
         .set_statistics_enabled(EnabledStatistics::Page)
-        .set_statistics_truncate_length(None)
-        .build())
+        .set_statistics_truncate_length(None);
+    for column in &settings.bloom_filter_enabled_columns {
+        let path = parquet_column_path(column);
+        let fpp = settings
+            .bloom_filter_fpp_by_column
+            .get(column)
+            .copied()
+            .unwrap_or(ICEBERG_DEFAULT_BLOOM_FILTER_FPP);
+        let ndv = settings.bloom_filter_ndv_by_column.get(column).copied();
+        let max_bytes = settings.bloom_filter_max_bytes as usize;
+        validate_bloom_filter_inputs(fpp, max_bytes)?;
+        let target_bytes = parquet_mr_bloom_filter_bytes(ndv, fpp, max_bytes);
+        let synthetic_ndv = synthetic_ndv_for_bloom_filter_bytes(target_bytes, 
fpp)?;
+        builder = builder
+            .set_column_bloom_filter_enabled(path.clone(), true)
+            .set_column_bloom_filter_fpp(path.clone(), fpp)
+            .set_column_bloom_filter_max_ndv(path, synthetic_ndv);

Review Comment:
   This is where the parity story breaks, and I do not think it is limited to 
the no-NDV case the description and tests frame it as.
   
   In parquet 59.3.0, `ColumnValueEncoderImpl::flush_bloom_filter` 
unconditionally calls `Sbbf::fold_to_target_fpp`, and `BloomFilterProperties` 
has no way to turn that off. Folding is driven purely by observed fill, so it 
happens whenever a row group is sparser than the design density, regardless of 
whether NDV was set. With an explicit NDV that is typically the table-wide 
cardinality, most row groups hold fewer distinct values than that, so they fold 
too. The byte-identity tests pass because they insert exactly the design 
cardinality.
   
   The effect on readers is the opposite of what the PR description argues for. 
Java keeps the full allocation, so its real false-positive rate on a 
low-cardinality column is close to zero. Comet folds until the estimated rate 
approaches the configured FPP, so a point lookup on such a column prunes up to 
about 1% fewer row groups than it would on a Java-written file. That is within 
the user's configured contract, but it is a measurable pruning regression 
against the classic writer, which is exactly what the sizing work here set out 
to avoid.
   
   The bitset content depends only on size and inserted values, which the 
folded-versus-JVM test in the action suite demonstrates nicely. So an upstream 
`BloomFilterProperties` toggle to skip folding would give exact parity in every 
case. Until that exists, could we gate this behind a Comet config that defaults 
to the JVM path when any bloom column is enabled, or at minimum make the docs 
state plainly that native filters are smaller and have a higher realised FPP 
than Java's for sparse row groups? The current `iceberg-writes.md` wording of 
"preserving the requested FPP" reads as if nothing observable changes.



##########
native/core/src/execution/operators/iceberg_write.rs:
##########
@@ -665,8 +666,146 @@ fn build_writer_properties(settings: 
&IcebergParquetWriteSettings) -> DFResult<W
         .set_dictionary_page_size_limit(settings.dict_size_bytes as usize)
         .set_data_page_row_count_limit(settings.page_row_limit as usize)
         .set_statistics_enabled(EnabledStatistics::Page)
-        .set_statistics_truncate_length(None)
-        .build())
+        .set_statistics_truncate_length(None);
+    for column in &settings.bloom_filter_enabled_columns {
+        let path = parquet_column_path(column);
+        let fpp = settings
+            .bloom_filter_fpp_by_column
+            .get(column)
+            .copied()
+            .unwrap_or(ICEBERG_DEFAULT_BLOOM_FILTER_FPP);
+        let ndv = settings.bloom_filter_ndv_by_column.get(column).copied();
+        let max_bytes = settings.bloom_filter_max_bytes as usize;
+        validate_bloom_filter_inputs(fpp, max_bytes)?;
+        let target_bytes = parquet_mr_bloom_filter_bytes(ndv, fpp, max_bytes);
+        let synthetic_ndv = synthetic_ndv_for_bloom_filter_bytes(target_bytes, 
fpp)?;
+        builder = builder
+            .set_column_bloom_filter_enabled(path.clone(), true)
+            .set_column_bloom_filter_fpp(path.clone(), fpp)
+            .set_column_bloom_filter_max_ndv(path, synthetic_ndv);
+    }
+    Ok(builder.build())
+}
+
+/// Convert the dot-separated physical path supplied by Iceberg Java into 
parquet-rs path parts.
+/// `ColumnPath::from(&str)` creates one literal part and therefore cannot 
represent nested leaves.
+fn parquet_column_path(path: &str) -> ColumnPath {
+    ColumnPath::from(path.split('.').map(str::to_owned).collect::<Vec<_>>())
+}
+
+// Match Apache Parquet Java's BlockSplitBloomFilter implementation bounds:
+// 
https://github.com/apache/parquet-java/blob/78a8d3230eb4769db93de5f2f2e18363c04cae81/parquet-column/src/main/java/org/apache/parquet/column/values/bloomfilter/BlockSplitBloomFilter.java#L40-L50
+const BLOOM_FILTER_MIN_BYTES: usize = 32;
+const BLOOM_FILTER_MAX_BYTES: usize = 128 * 1024 * 1024;
+const BLOOM_FILTER_HASH_PROBES: f64 = 8.0;
+const ICEBERG_DEFAULT_BLOOM_FILTER_FPP: f64 = 0.01;
+#[cfg(test)]
+const ICEBERG_DEFAULT_BLOOM_FILTER_MAX_BYTES: usize = 1024 * 1024;
+
+/// The positive denominator obtained by solving the Bloom-filter 
false-positive equation
+/// `fpp = (1 - exp(-k * ndv / bits))^k` for `bits`, with the Parquet SBBF's 
`k = 8` probes.
+///
+/// See the Apache Arrow Rust `parquet` implementation and its cited paper:
+/// 
https://github.com/apache/arrow-rs/blob/58.4.0/parquet/src/bloom_filter/mod.rs#L369-L376

Review Comment:
   These links, the ones at line 749 and the PR description all cite arrow-rs 
58.4.0, but the lockfile resolves parquet 59.3.0. That matters here because 59 
is exactly where `max_ndv` semantics and post-insert folding arrived, and the 
doc comments on `parquet_rs_bloom_filter_bytes` describe the 58 sizing 
behaviour without mentioning that the allocation is then folded. Could we point 
these at 59.3.0 and note the fold?



##########
spark/src/main/scala/org/apache/comet/serde/operator/CometIcebergNativeWrite.scala:
##########
@@ -283,6 +284,156 @@ object CometIcebergNativeWrite extends 
CometOperatorSerde[IcebergWriteExec] {
   private val requireNativeSupportedCompressionLevel: TriggerRule = ctx =>
     IcebergWriteProtoTranslation.compressionLevelRejection(ctx.properties)
 
+  // These are Apache Parquet Java BlockSplitBloomFilter implementation 
bounds, not Iceberg
+  // TableProperties constants, so they cannot be obtained through 
IcebergReflection:
+  // scalastyle:off line.size.limit
+  // 
https://github.com/apache/parquet-java/blob/78a8d3230eb4769db93de5f2f2e18363c04cae81/parquet-column/src/main/java/org/apache/parquet/column/values/bloomfilter/BlockSplitBloomFilter.java#L40-L50
+  // scalastyle:on line.size.limit
+  private val MinBloomFilterBytes = 32
+  private val MaxBloomFilterBytes = 128 * 1024 * 1024
+  private val BloomFilterHashProbes = 8
+  private val MaxNonOverflowingBloomFilterNdv = Long.MaxValue / 
BloomFilterHashProbes
+
+  /**
+   * Keep only Bloom shape properties interpreted by the Iceberg runtime on 
the classpath. Older
+   * Iceberg releases leave these table properties untouched but do not pass 
them to parquet-mr.
+   * Ignoring them here preserves that version's JVM-writer behavior while 
allowing the remaining
+   * supported Bloom configuration to execute natively.
+   */
+  private def interpretedBloomFilterProperties(
+      properties: Map[String, String]): Map[String, String] = {
+    val unsupportedPrefixes = Seq(
+      PropertyKeys.ParquetBloomFilterColumnFppPrefix ->
+        "PARQUET_BLOOM_FILTER_COLUMN_FPP_PREFIX",
+      PropertyKeys.ParquetBloomFilterColumnNdvPrefix ->
+        "PARQUET_BLOOM_FILTER_COLUMN_NDV_PREFIX").collect {
+      case (prefix, constant) if 
IcebergReflection.tablePropertyConstantOpt(constant).isEmpty =>
+        prefix
+    }
+    properties.filterNot { case (key, _) => 
unsupportedPrefixes.exists(key.startsWith) }
+  }
+
+  /**
+   * parquet-rs 58.x represents Bloom filters as a power-of-two number of 
bytes. parquet-mr
+   * accepts arbitrary caps and, when one binds, serializes that exact length. 
Keep those writes
+   * on the classic path instead of silently changing the number of usable 
Bloom blocks.
+   */
+  private val requireNativeSupportedBloomFilterProperties: TriggerRule = ctx 
=> {
+    val properties = interpretedBloomFilterProperties(ctx.properties)
+    val maxRejection =

Review Comment:
   This rejects a non-power-of-two `write.parquet.bloom-filter-max-bytes` even 
when no column has a bloom filter enabled, in which case the value never 
influences the written file. Could the check be skipped when `configured` is 
empty so those tables stay on the native path?



##########
native/core/src/execution/operators/iceberg_write.rs:
##########
@@ -665,8 +666,146 @@ fn build_writer_properties(settings: 
&IcebergParquetWriteSettings) -> DFResult<W
         .set_dictionary_page_size_limit(settings.dict_size_bytes as usize)
         .set_data_page_row_count_limit(settings.page_row_limit as usize)
         .set_statistics_enabled(EnabledStatistics::Page)
-        .set_statistics_truncate_length(None)
-        .build())
+        .set_statistics_truncate_length(None);
+    for column in &settings.bloom_filter_enabled_columns {
+        let path = parquet_column_path(column);
+        let fpp = settings
+            .bloom_filter_fpp_by_column
+            .get(column)
+            .copied()
+            .unwrap_or(ICEBERG_DEFAULT_BLOOM_FILTER_FPP);
+        let ndv = settings.bloom_filter_ndv_by_column.get(column).copied();
+        let max_bytes = settings.bloom_filter_max_bytes as usize;
+        validate_bloom_filter_inputs(fpp, max_bytes)?;
+        let target_bytes = parquet_mr_bloom_filter_bytes(ndv, fpp, max_bytes);
+        let synthetic_ndv = synthetic_ndv_for_bloom_filter_bytes(target_bytes, 
fpp)?;
+        builder = builder
+            .set_column_bloom_filter_enabled(path.clone(), true)
+            .set_column_bloom_filter_fpp(path.clone(), fpp)
+            .set_column_bloom_filter_max_ndv(path, synthetic_ndv);
+    }
+    Ok(builder.build())
+}
+
+/// Convert the dot-separated physical path supplied by Iceberg Java into 
parquet-rs path parts.
+/// `ColumnPath::from(&str)` creates one literal part and therefore cannot 
represent nested leaves.
+fn parquet_column_path(path: &str) -> ColumnPath {
+    ColumnPath::from(path.split('.').map(str::to_owned).collect::<Vec<_>>())
+}
+
+// Match Apache Parquet Java's BlockSplitBloomFilter implementation bounds:
+// 
https://github.com/apache/parquet-java/blob/78a8d3230eb4769db93de5f2f2e18363c04cae81/parquet-column/src/main/java/org/apache/parquet/column/values/bloomfilter/BlockSplitBloomFilter.java#L40-L50
+const BLOOM_FILTER_MIN_BYTES: usize = 32;
+const BLOOM_FILTER_MAX_BYTES: usize = 128 * 1024 * 1024;
+const BLOOM_FILTER_HASH_PROBES: f64 = 8.0;
+const ICEBERG_DEFAULT_BLOOM_FILTER_FPP: f64 = 0.01;
+#[cfg(test)]
+const ICEBERG_DEFAULT_BLOOM_FILTER_MAX_BYTES: usize = 1024 * 1024;
+
+/// The positive denominator obtained by solving the Bloom-filter 
false-positive equation
+/// `fpp = (1 - exp(-k * ndv / bits))^k` for `bits`, with the Parquet SBBF's 
`k = 8` probes.
+///
+/// See the Apache Arrow Rust `parquet` implementation and its cited paper:
+/// 
https://github.com/apache/arrow-rs/blob/58.4.0/parquet/src/bloom_filter/mod.rs#L369-L376
+/// http://algo2.iti.kit.edu/documents/cacheefficientbloomfilters-jea.pdf
+fn bloom_filter_fpp_denominator(fpp: f64) -> f64 {
+    -(1.0 - fpp.powf(1.0 / BLOOM_FILTER_HASH_PROBES)).ln()
+}
+
+/// Reproduce parquet-mr's non-adaptive allocation decision before translating 
the resulting
+/// power-of-two byte size into parquet-rs's NDV-shaped API. An absent NDV 
requests the full cap;
+/// an explicit NDV sizes from NDV/FPP and then applies the cap. The native 
eligibility gate only
+/// admits representable power-of-two caps.
+///
+/// Apache Parquet Java implementation:
+/// 
https://github.com/apache/parquet-java/blob/78a8d3230eb4769db93de5f2f2e18363c04cae81/parquet-column/src/main/java/org/apache/parquet/column/values/bloomfilter/BlockSplitBloomFilter.java#L277-L301
+/// 
https://github.com/apache/parquet-java/blob/78a8d3230eb4769db93de5f2f2e18363c04cae81/parquet-column/src/main/java/org/apache/parquet/column/values/bloomfilter/BlockSplitBloomFilter.java#L195-L218
+fn parquet_mr_bloom_filter_bytes(ndv: Option<u64>, fpp: f64, max_bytes: usize) 
-> usize {
+    let Some(ndv) = ndv else {
+        return max_bytes;
+    };
+
+    let calculated = BLOOM_FILTER_HASH_PROBES * ndv as f64 / 
bloom_filter_fpp_denominator(fpp);
+    let mut num_bits = calculated as i32;
+    let upper_bits = (BLOOM_FILTER_MAX_BYTES * 8) as i32;
+    if num_bits > upper_bits || calculated < 0.0 {
+        num_bits = upper_bits;
+    }
+    // This deliberately mirrors parquet-mr 1.17's integer expression, 
including its unusual
+    // mask, so allocation thresholds remain compatible rather than merely 
mathematically close.
+    num_bits = (num_bits + 255) & !256;
+    num_bits = num_bits.max((BLOOM_FILTER_MIN_BYTES * 8) as i32);
+    let requested = (num_bits as usize) / 8;
+    let allocated = requested
+        .clamp(BLOOM_FILTER_MIN_BYTES, BLOOM_FILTER_MAX_BYTES)
+        .next_power_of_two();
+    // The eligibility gate excludes max=32 when this cap would change 
parquet-mr's allocation.
+    allocated.min(max_bytes)
+}
+
+/// Mirror the NDV/FPP sizing and power-of-two allocation used by the Apache 
Arrow Rust `parquet`
+/// crate. Its source derives the formula from the standard Bloom-filter 
false-positive equation
+/// with eight hash probes and links the underlying cache-efficient 
Bloom-filter paper:
+/// 
https://github.com/apache/arrow-rs/blob/58.4.0/parquet/src/bloom_filter/mod.rs#L363-L395
+fn parquet_rs_bloom_filter_bytes(ndv: u64, fpp: f64) -> usize {
+    let num_bits =
+        (BLOOM_FILTER_HASH_PROBES * ndv as f64 / 
bloom_filter_fpp_denominator(fpp)) as usize;
+    (num_bits / 8)
+        .clamp(BLOOM_FILTER_MIN_BYTES, BLOOM_FILTER_MAX_BYTES)
+        .next_power_of_two()
+}
+
+/// Encode an exact power-of-two allocation using parquet-rs 58.x's public 
NDV/FPP setters.
+///
+/// A target `B > 32` is selected by every raw byte count in `(B/2, B]`. Aim 
at `3B/4`, far from
+/// either floating-point boundary, and verify using the exact parquet-rs 
sizing expression. The
+/// binary-search fallback covers unusual but still representable FPP values 
without relying on
+/// the inverse formula landing on a particular floating-point integer.
+fn synthetic_ndv_for_bloom_filter_bytes(target_bytes: usize, fpp: f64) -> 
DFResult<u64> {
+    let fpp_denominator = bloom_filter_fpp_denominator(fpp);
+    // Any raw size in (B / 2, B] rounds up to the target power-of-two 
allocation B. Choose the
+    // midpoint of that interval to stay away from floating-point boundaries 
at either end.
+    let raw_target_bytes = target_bytes as f64 * 3.0 / 4.0;
+    let candidate = ((raw_target_bytes * fpp_denominator).round() as 
u64).max(1);
+    if parquet_rs_bloom_filter_bytes(candidate, fpp) == target_bytes {
+        return Ok(candidate);
+    }
+
+    // Rust's standard binary-search helpers operate on materialized slices; 
this is a lower-bound
+    // search over the implicit NDV domain `1..=u64::MAX`, so keep the numeric 
search explicit.
+    let mut low = 1_u64;
+    let mut high = u64::MAX;
+    while low < high {
+        let mid = low + (high - low) / 2;
+        if parquet_rs_bloom_filter_bytes(mid, fpp) < target_bytes {
+            low = mid.saturating_add(1);
+        } else {
+            high = mid;
+        }
+    }
+    if parquet_rs_bloom_filter_bytes(low, fpp) == target_bytes {
+        Ok(low)
+    } else {
+        Err(DataFusionError::Internal(format!(
+            "FPP {fpp} cannot represent a {target_bytes}-byte parquet-rs Bloom 
filter"
+        )))
+    }
+}
+
+fn validate_bloom_filter_inputs(fpp: f64, bytes: usize) -> DFResult<()> {
+    if !fpp.is_finite() || !(0.0..1.0).contains(&fpp) {

Review Comment:
   `(0.0..1.0).contains(&fpp)` accepts an FPP of exactly `0.0`, while the 
message says strictly between 0 and 1 and parquet-java's `withBloomFilterFPP` 
rejects it. The JVM gate makes this unreachable today, but the native check is 
the last line of defence if that gate ever changes, so I would make it `fpp > 
0.0 && fpp < 1.0`.



##########
spark/src/test/scala/org/apache/comet/CometIcebergWriteActionSuite.scala:
##########
@@ -600,6 +604,453 @@ class CometIcebergWriteActionSuite
     }
   }
 
+  test("native acceleration: writes configured Iceberg Parquet bloom filters") 
{
+    assumeNativeAcceleration()
+    withIcebergCatalog { warehouseDir =>
+      createTable(
+        warehouseDir,
+        "native_bloom",
+        partitionSpec = "",
+        properties = 
Some("'write.parquet.bloom-filter-enabled.column.id'='true'"))
+
+      val expectedIds = 0 until 256
+      assertNativeWriteEngages("native_bloom", expectedIds) {
+        spark.sql(
+          "INSERT INTO cat.db.native_bloom " +
+            "SELECT CAST(id AS INT), CONCAT('region-', CAST(id % 4 AS 
STRING)), " +
+            "CAST(id AS DOUBLE) FROM range(256)")
+      }
+
+      assertParquetBloomFilters("native_bloom", enabledColumns = Set("id"))
+    }
+  }
+
+  test("enabled=false plus NDV matches JVM behavior for the Iceberg runtime") {
+    assumeNativeAcceleration()
+    withIcebergCatalog { warehouseDir =>
+      val configuredFpp = 0.01
+      val configuredNdv = 1000
+      val properties = Some(
+        "'write.parquet.bloom-filter-enabled.column.id'='false', " +
+          s"'write.parquet.bloom-filter-fpp.column.id'='$configuredFpp', " +
+          s"'write.parquet.bloom-filter-ndv.column.id'='$configuredNdv'")
+      createTable(warehouseDir, "bloom_false_ndv_native", partitionSpec = "", 
properties)
+      createTable(warehouseDir, "bloom_false_ndv_jvm", partitionSpec = "", 
properties)
+
+      def insert(table: String): Unit = spark.sql(
+        s"INSERT INTO cat.db.$table " +
+          "SELECT CAST(id AS INT), 'region', CAST(id AS DOUBLE) " +
+          s"FROM range(0, $configuredNdv, 1, 1)")
+
+      assertNativeWriteEngages("bloom_false_ndv_native", 0 until 
configuredNdv) {
+        insert("bloom_false_ndv_native")
+      }
+      withSQLConf(CometConf.COMET_ICEBERG_NATIVE_WRITE_ENABLED.key -> "false") 
{
+        insert("bloom_false_ndv_jvm")
+      }
+
+      val nativeHasBloom = parquetBloomFilterPresent("bloom_false_ndv_native", 
"id")
+      val jvmHasBloom = parquetBloomFilterPresent("bloom_false_ndv_jvm", "id")
+      assert(nativeHasBloom == jvmHasBloom)
+      if (icebergSupportsBloomNdv) {
+        assert(nativeHasBloom, "an interpreted NDV must re-enable the Bloom 
filter")
+        val native = parquetBloomFilterBytes("bloom_false_ndv_native", "id")
+        val jvm = parquetBloomFilterBytes("bloom_false_ndv_jvm", "id")
+        assert(native.map(_.length) == jvm.map(_.length))
+        assert(native.zip(jvm).forall { case (left, right) =>
+          java.util.Arrays.equals(left, right)
+        })
+      } else {
+        assert(!nativeHasBloom, "an uninterpreted NDV must not override 
enabled=false")
+      }
+    }
+  }
+
+  test("enabled=false preserves JVM validation errors for malformed FPP and 
NDV") {
+    assumeNativeAcceleration()
+    withIcebergCatalog { warehouseDir =>
+      Seq(
+        ("fpp", "'write.parquet.bloom-filter-fpp.column.id'='garbage'", 
icebergSupportsBloomFpp),
+        ("ndv", "'write.parquet.bloom-filter-ndv.column.id'='garbage'", 
icebergSupportsBloomNdv))
+        .foreach { case (suffix, malformedProperty, interpreted) =>
+          val properties =
+            Some(s"'write.parquet.bloom-filter-enabled.column.id'='false', 
$malformedProperty")
+          val nativeTable = s"bloom_false_bad_${suffix}_native"
+          val jvmTable = s"bloom_false_bad_${suffix}_jvm"
+          Seq(nativeTable, jvmTable).foreach { table =>
+            createTable(warehouseDir, table, partitionSpec = "", properties = 
properties)
+          }
+
+          def insert(table: String): Unit =
+            spark.sql(s"INSERT INTO cat.db.$table VALUES (1, 'region', 1.0)")
+
+          if (interpreted) {
+            val withComet = intercept[Throwable] {
+              withNativeEnabled(insert(nativeTable))
+            }
+            val withJvm = intercept[Throwable] {
+              withSQLConf(CometConf.COMET_ICEBERG_NATIVE_WRITE_ENABLED.key -> 
"false") {
+                insert(jvmTable)
+              }
+            }
+            val cometCause = exceptionChain(withComet).last
+            val jvmCause = exceptionChain(withJvm).last
+            assert(cometCause.getClass == jvmCause.getClass)
+            assert(cometCause.getMessage == jvmCause.getMessage)
+          } else {
+            assertNativeWriteEngages(nativeTable, Seq(1))(insert(nativeTable))
+            withSQLConf(CometConf.COMET_ICEBERG_NATIVE_WRITE_ENABLED.key -> 
"false") {
+              insert(jvmTable)
+            }
+            assert(
+              !parquetBloomFilterPresent(nativeTable, "id") &&
+                !parquetBloomFilterPresent(jvmTable, "id"),
+              s"uninterpreted $suffix must not enable a Bloom filter")
+          }
+        }
+    }
+  }
+
+  test("max-bytes=32 falls back only when parquet-mr ignores the cap") {
+    assumeNativeAcceleration()
+    assumeIcebergBloomShapeProperties()
+    withIcebergCatalog { warehouseDir =>
+      val minimumBytes = 32
+      val naturallyMinimumNdv = 1
+      val bindingNdv = 1000000
+      val bindingFpp = 0.0001
+      val parquetMrBindingBytes = 4 * 1024 * 1024
+      val enabled = "'write.parquet.bloom-filter-enabled.column.id'='true', "
+      createTable(
+        warehouseDir,
+        "bloom_minimum_native",
+        partitionSpec = "",
+        properties = Some(
+          enabled + 
s"'write.parquet.bloom-filter-ndv.column.id'='$naturallyMinimumNdv', " +
+            s"'write.parquet.bloom-filter-max-bytes'='$minimumBytes'"))
+      createTable(
+        warehouseDir,
+        "bloom_minimum_fallback",
+        partitionSpec = "",
+        properties = Some(
+          enabled + 
s"'write.parquet.bloom-filter-fpp.column.id'='$bindingFpp', " +
+            s"'write.parquet.bloom-filter-ndv.column.id'='$bindingNdv', " +
+            s"'write.parquet.bloom-filter-max-bytes'='$minimumBytes'"))
+
+      assertNativeWriteEngages("bloom_minimum_native", Seq(1)) {
+        spark.sql("INSERT INTO cat.db.bloom_minimum_native VALUES (1, 
'region', 1.0)")
+      }
+      assertNativeWriteDoesNotEngage("bloom_minimum_fallback", Seq(1)) {
+        spark.sql("INSERT INTO cat.db.bloom_minimum_fallback VALUES (1, 
'region', 1.0)")
+      }
+
+      assert(
+        parquetBloomFilterBytes("bloom_minimum_native", "id").forall(_.length 
== minimumBytes))
+      assert(
+        parquetBloomFilterBytes("bloom_minimum_fallback", "id")
+          .forall(_.length == parquetMrBindingBytes))
+    }
+  }
+
+  test("native bloom filters resolve list and map leaves to physical Parquet 
paths") {
+    assumeNativeAcceleration()
+    withIcebergCatalog { _ =>
+      spark.sql(s"""
+        CREATE TABLE $catalog.$ns.native_nested_bloom (
+          id INT,
+          tags ARRAY<STRING>,
+          attrs MAP<STRING, INT>
+        ) USING iceberg
+        TBLPROPERTIES (
+          'write.parquet.bloom-filter-enabled.column.tags.element'='true',
+          'write.parquet.bloom-filter-enabled.column.attrs.key'='true',
+          'write.parquet.bloom-filter-enabled.column.attrs.value'='true'
+        )
+      """)
+
+      assertNativeWriteEngages("native_nested_bloom", Seq(1, 2)) {
+        spark.sql("""
+          INSERT INTO cat.db.native_nested_bloom VALUES
+            (1, array('red', 'green'), map('small', 10, 'large', 20)),
+            (2, array('blue'), map('medium', 30))
+        """)
+      }
+
+      assertParquetBloomFilters(
+        "native_nested_bloom",
+        enabledColumns = Set("tags.list.element", "attrs.key_value.key", 
"attrs.key_value.value"))
+    }
+  }
+
+  test("quoted bloom-filter columns renamed by Iceberg fall back to the JVM 
footer") {
+    assumeNativeAcceleration()
+    withIcebergCatalog { _ =>
+      Seq("bloom_quoted_name", "bloom_quoted_name_jvm").foreach { table =>
+        spark.sql(s"""
+          CREATE TABLE $catalog.$ns.$table (
+            `order id` INT
+          ) USING iceberg
+          TBLPROPERTIES (
+            'write.parquet.bloom-filter-enabled.column.order id'='true'
+          )
+        """)
+      }
+
+      val snapshot = withNativeEnabled {
+        captureWrite("bloom_quoted_name") {
+          spark.sql(s"INSERT INTO $catalog.$ns.bloom_quoted_name VALUES (1), 
(2)")
+        }
+      }
+      assertExactlyOneCommit(snapshot)
+      val nativeExecs = snapshot.plans.flatMap { plan =>
+        collectWithSubqueries(plan) { case exec: CometIcebergWriteExec => exec 
}
+      }
+      assert(
+        nativeExecs.isEmpty,
+        s"expected the sanitized Bloom-filter path to fall back, 
plans:\n${snapshot.plans.mkString("\n--\n")}")
+      withSQLConf(CometConf.COMET_ICEBERG_NATIVE_WRITE_ENABLED.key -> "false") 
{
+        spark.sql(s"INSERT INTO $catalog.$ns.bloom_quoted_name_jvm VALUES (1), 
(2)")
+      }
+      val fallbackHasBloom = parquetBloomFilterPresent("bloom_quoted_name", 
"order_x20id")
+      val jvmHasBloom = parquetBloomFilterPresent("bloom_quoted_name_jvm", 
"order_x20id")
+      assert(
+        fallbackHasBloom == jvmHasBloom,
+        "expected the fallback write to match the Parquet Java footer")
+      if (icebergSupportsBloomFpp) {
+        assert(jvmHasBloom, "expected this Iceberg version to write the 
quoted-column filter")
+      }
+    }
+  }
+
+  test("native bloom filter is byte-identical to parquet-mr when max-bytes 
binds") {
+    assumeNativeAcceleration()
+    assumeIcebergBloomShapeProperties()
+    withIcebergCatalog { warehouseDir =>
+      // This NDV/FPP pair requests a 128 MiB allocation before max-bytes is 
applied. The 4 KiB
+      // maximum must therefore bind on both writers rather than merely 
coinciding with the
+      // naturally selected size.
+      val configuredNdv = 100000000L
+      val bindingMaxBytes = 4 * 1024
+      val properties = Some(
+        "'write.parquet.bloom-filter-enabled.column.id'='true', " +
+          "'write.parquet.bloom-filter-fpp.column.id'='0.01', " +
+          s"'write.parquet.bloom-filter-ndv.column.id'='$configuredNdv', " +
+          s"'write.parquet.bloom-filter-max-bytes'='$bindingMaxBytes'")
+      createTable(warehouseDir, "bloom_identity_native", partitionSpec = "", 
properties)
+      createTable(warehouseDir, "bloom_identity_jvm", partitionSpec = "", 
properties)
+
+      def insert(table: String): Unit = spark.sql(
+        s"INSERT INTO cat.db.$table " +
+          "SELECT CAST(id AS INT), 'region', CAST(id AS DOUBLE) FROM range(0, 
10000, 1, 1)")
+
+      assertNativeWriteEngages("bloom_identity_native", 0 until 10000) {
+        insert("bloom_identity_native")
+      }
+      withSQLConf(CometConf.COMET_ICEBERG_NATIVE_WRITE_ENABLED.key -> "false") 
{
+        insert("bloom_identity_jvm")
+      }
+
+      val native = parquetBloomFilterBytes("bloom_identity_native", "id")
+      val jvm = parquetBloomFilterBytes("bloom_identity_jvm", "id")
+      assert(
+        native.nonEmpty && native.forall(_.length == bindingMaxBytes),
+        s"native Bloom filter must be capped at $bindingMaxBytes bytes")
+      assert(
+        jvm.nonEmpty && jvm.forall(_.length == bindingMaxBytes),
+        s"JVM Bloom filter must be capped at $bindingMaxBytes bytes")
+      assert(native.size == jvm.size, "native and JVM writes must produce the 
same file count")
+      assert(
+        native.zip(jvm).forall { case (left, right) => 
java.util.Arrays.equals(left, right) },
+        "expected byte-identical capped SBBF bitsets for identical values and 
allocation")
+    }
+  }
+
+  test("native bloom sizing matches JVM for shape properties supported by the 
Iceberg runtime") {
+    assumeNativeAcceleration()
+    withIcebergCatalog { warehouseDir =>
+      val enabled = "'write.parquet.bloom-filter-enabled.column.id'='true'"
+      val cases: Seq[(String, String, Boolean, Boolean)] = Seq(
+        (
+          "fpp_only",
+          s"$enabled, 'write.parquet.bloom-filter-fpp.column.id'='0.02'",
+          icebergSupportsBloomFpp,
+          false),
+        (
+          "ndv_only",
+          s"$enabled, 'write.parquet.bloom-filter-ndv.column.id'='1000'",
+          icebergSupportsBloomNdv,
+          true),
+        (
+          "both",
+          s"$enabled, 'write.parquet.bloom-filter-fpp.column.id'='0.005', " +
+            "'write.parquet.bloom-filter-ndv.column.id'='1000'",
+          icebergSupportsBloomFpp && icebergSupportsBloomNdv,
+          true),
+        // The requested NDV/FPP needs far more than 64 bytes. Like 
parquet-mr, max wins and the
+        // target FPP becomes impossible to guarantee, but membership must 
remain correct.
+        (
+          "binding_cap",
+          s"$enabled, 'write.parquet.bloom-filter-fpp.column.id'='0.0001', " +
+            "'write.parquet.bloom-filter-ndv.column.id'='1000000', " +
+            "'write.parquet.bloom-filter-max-bytes'='64'",
+          icebergSupportsBloomFpp && icebergSupportsBloomNdv,
+          true))
+
+      cases.filter(_._3).foreach { case (suffix, properties, _, 
expectByteIdentity) =>
+        val nativeTable = s"bloom_shape_${suffix}_native"
+        val jvmTable = s"bloom_shape_${suffix}_jvm"
+        Seq(nativeTable, jvmTable).foreach { table =>
+          createTable(warehouseDir, table, partitionSpec = "", properties = 
Some(properties))
+        }
+        // Keep the explicit-NDV cases at their estimated cardinality so 
parquet-rs does not
+        // fold their allocation before byte-parity is checked.
+        val insertedCardinality = if (suffix == "ndv_only" || suffix == 
"both") 1000 else 256
+        val ids = 0 until insertedCardinality
+        def insert(table: String): Unit =
+          spark.sql(s"INSERT INTO cat.db.$table SELECT CAST(id AS INT), 
'region', " +
+            s"CAST(id AS DOUBLE) FROM range(${ids.start}, ${ids.end}, 1, 1)")
+
+        assertNativeWriteEngages(nativeTable, ids) {
+          insert(nativeTable)
+        }
+        withSQLConf(CometConf.COMET_ICEBERG_NATIVE_WRITE_ENABLED.key -> 
"false") {
+          insert(jvmTable)
+        }
+        val native = parquetBloomFilterBytes(nativeTable, "id")
+        val jvm = parquetBloomFilterBytes(jvmTable, "id")
+        if (expectByteIdentity) {
+          assert(native.map(_.length) == jvm.map(_.length))
+          assert(native.zip(jvm).forall { case (left, right) =>
+            java.util.Arrays.equals(left, right)
+          })
+        } else {
+          // Without explicit NDV, parquet-rs can fold to the observed 
cardinality while Parquet

Review Comment:
   Folding is not specific to the no-NDV case. parquet-rs folds on observed 
fill, so the explicit-NDV cases avoid it only because the test inserts exactly 
the design cardinality. It might be worth adding a case with an explicit NDV 
and fewer inserted distinct values, and asserting the fold there, so the 
divergence from Java is documented by a test rather than hidden by data choice.



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