This is an automated email from the ASF dual-hosted git repository.
mbutrovich pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git
The following commit(s) were added to refs/heads/main by this push:
new 0761e549a4 perf: dedupe Iceberg residuals and delete files in native
scan serde (#4982)
0761e549a4 is described below
commit 0761e549a43fee618af462a305645e70d663a380
Author: Matt Butrovich <[email protected]>
AuthorDate: Thu Jul 23 13:42:05 2026 -0400
perf: dedupe Iceberg residuals and delete files in native scan serde (#4982)
---
native/core/src/execution/planner.rs | 476 ++++++++-----------
native/proto/src/proto/operator.proto | 103 ++++-
.../apache/comet/iceberg/IcebergReflection.scala | 36 ++
.../serde/operator/CometIcebergNativeScan.scala | 505 ++++++++++++++-------
.../org/apache/comet/CometFuzzIcebergBase.scala | 2 +-
.../org/apache/comet/CometFuzzIcebergSuite.scala | 206 +++++----
.../org/apache/comet/CometIcebergNativeSuite.scala | 496 ++++++++++++++++++++
7 files changed, 1252 insertions(+), 572 deletions(-)
diff --git a/native/core/src/execution/planner.rs
b/native/core/src/execution/planner.rs
index 7c24bcabba..e43daed9a5 100644
--- a/native/core/src/execution/planner.rs
+++ b/native/core/src/execution/planner.rs
@@ -3547,59 +3547,32 @@ fn align_shuffle_writer_input(
fn partition_value_to_literal(
proto_value: &spark_operator::PartitionValue,
) -> Result<Option<iceberg::spec::Literal>, ExecutionError> {
- use spark_operator::partition_value::Value;
+ use spark_operator::iceberg_literal::Value;
- if proto_value.is_null {
+ let Some(lit) = proto_value.literal.as_ref() else {
+ return Err(GeneralError(
+ "PartitionValue has no literal set".to_string(),
+ ));
+ };
+
+ if lit.is_null {
return Ok(None);
}
- let literal = match &proto_value.value {
+ let literal = match &lit.value {
+ Some(Value::BoolVal(v)) => iceberg::spec::Literal::bool(*v),
Some(Value::IntVal(v)) => iceberg::spec::Literal::int(*v),
Some(Value::LongVal(v)) => iceberg::spec::Literal::long(*v),
- Some(Value::DateVal(v)) => {
- // Convert i64 to i32 for date (days since epoch)
- let days = (*v)
- .try_into()
- .map_err(|_| GeneralError(format!("Date value out of range:
{}", v)))?;
- iceberg::spec::Literal::date(days)
- }
+ Some(Value::FloatVal(v)) => iceberg::spec::Literal::float(*v),
+ Some(Value::DoubleVal(v)) => iceberg::spec::Literal::double(*v),
+ Some(Value::DateVal(v)) => iceberg::spec::Literal::date(*v),
Some(Value::TimestampVal(v)) => iceberg::spec::Literal::timestamp(*v),
Some(Value::TimestampTzVal(v)) =>
iceberg::spec::Literal::timestamptz(*v),
Some(Value::StringVal(s)) => iceberg::spec::Literal::string(s.clone()),
- Some(Value::DoubleVal(v)) => iceberg::spec::Literal::double(*v),
- Some(Value::FloatVal(v)) => iceberg::spec::Literal::float(*v),
- Some(Value::DecimalVal(bytes)) => {
- // Deserialize unscaled BigInteger bytes to i128
- // BigInteger is serialized as signed big-endian bytes
- if bytes.len() > 16 {
- return Err(GeneralError(format!(
- "Decimal bytes too large: {} bytes (max 16 for i128)",
- bytes.len()
- )));
- }
-
- // Convert big-endian bytes to i128
- let mut buf = [0u8; 16];
- let offset = 16 - bytes.len();
- buf[offset..].copy_from_slice(bytes);
-
- // Handle sign extension for negative numbers
- let value = if !bytes.is_empty() && (bytes[0] & 0x80) != 0 {
- // Negative number - sign extend
- for byte in buf.iter_mut().take(offset) {
- *byte = 0xFF;
- }
- i128::from_be_bytes(buf)
- } else {
- // Positive number
- i128::from_be_bytes(buf)
- };
-
- iceberg::spec::Literal::decimal(value)
+ Some(Value::DecimalVal(d)) => {
+
iceberg::spec::Literal::decimal(decimal_bytes_to_i128(&d.unscaled)?)
}
- Some(Value::BoolVal(v)) => iceberg::spec::Literal::bool(*v),
Some(Value::UuidVal(bytes)) => {
- // Deserialize UUID from 16 bytes
if bytes.len() != 16 {
return Err(GeneralError(format!(
"Invalid UUID bytes length: {} (expected 16)",
@@ -3614,7 +3587,7 @@ fn partition_value_to_literal(
Some(Value::BinaryVal(bytes)) =>
iceberg::spec::Literal::binary(bytes.to_vec()),
None => {
return Err(GeneralError(
- "PartitionValue has no value set and is_null is
false".to_string(),
+ "IcebergLiteral has no value set and is_null is
false".to_string(),
));
}
};
@@ -3622,6 +3595,26 @@ fn partition_value_to_literal(
Ok(Some(literal))
}
+/// Decodes an unscaled decimal (two's-complement big-endian) into i128.
+fn decimal_bytes_to_i128(bytes: &[u8]) -> Result<i128, ExecutionError> {
+ if bytes.len() > 16 {
+ return Err(GeneralError(format!(
+ "Decimal bytes too large: {} bytes (max 16 for i128)",
+ bytes.len()
+ )));
+ }
+ let mut buf = [0u8; 16];
+ let offset = 16 - bytes.len();
+ buf[offset..].copy_from_slice(bytes);
+ // Sign-extend negative values.
+ if !bytes.is_empty() && (bytes[0] & 0x80) != 0 {
+ for byte in buf.iter_mut().take(offset) {
+ *byte = 0xFF;
+ }
+ }
+ Ok(i128::from_be_bytes(buf))
+}
+
/// Converts a protobuf PartitionData to an iceberg Struct.
///
/// Uses the existing Struct::from_iter() API from iceberg-rust to construct
the struct
@@ -3684,36 +3677,53 @@ fn parse_file_scan_tasks_from_common(
})
.collect();
+ // Flat pool of unique delete files. A delete file applies to many data
files under Iceberg's
+ // default partition delete granularity, so DeleteFileList entries
reference this pool by index
+ // rather than embedding copies.
+ let delete_file_pool: Vec<iceberg::scan::FileScanTaskDeleteFile> =
proto_common
+ .delete_file_pool
+ .iter()
+ .map(|del| {
+ let file_type = match del.content_type.as_str() {
+ "POSITION_DELETES" =>
iceberg::spec::DataContentType::PositionDeletes,
+ "EQUALITY_DELETES" =>
iceberg::spec::DataContentType::EqualityDeletes,
+ other => {
+ return Err(GeneralError(format!(
+ "Invalid delete content type '{}'",
+ other
+ )))
+ }
+ };
+
+ Ok(iceberg::scan::FileScanTaskDeleteFile {
+ file_path: del.file_path.clone(),
+ file_type,
+ // Not serialized; filled in by
IcebergScanExec::fill_delete_file_sizes.
+ file_size_in_bytes: 0,
+ partition_spec_id: del.partition_spec_id,
+ equality_ids: if del.equality_ids.is_empty() {
+ None
+ } else {
+ Some(del.equality_ids.clone())
+ },
+ key_metadata: None,
+ })
+ })
+ .collect::<Result<Vec<_>, ExecutionError>>()?;
+
let delete_files_cache: Vec<Vec<iceberg::scan::FileScanTaskDeleteFile>> =
proto_common
.delete_files_pool
.iter()
.map(|list| {
- list.delete_files
+ list.delete_file_indices
.iter()
- .map(|del| {
- let file_type = match del.content_type.as_str() {
- "POSITION_DELETES" =>
iceberg::spec::DataContentType::PositionDeletes,
- "EQUALITY_DELETES" =>
iceberg::spec::DataContentType::EqualityDeletes,
- other => {
- return Err(GeneralError(format!(
- "Invalid delete content type '{}'",
- other
- )))
- }
- };
-
- Ok(iceberg::scan::FileScanTaskDeleteFile {
- file_path: del.file_path.clone(),
- file_type,
- // Not serialized; filled in by
IcebergScanExec::fill_delete_file_sizes.
- file_size_in_bytes: 0,
- partition_spec_id: del.partition_spec_id,
- equality_ids: if del.equality_ids.is_empty() {
- None
- } else {
- Some(del.equality_ids.clone())
- },
- key_metadata: None,
+ .map(|&idx| {
+ delete_file_pool.get(idx as usize).cloned().ok_or_else(|| {
+ GeneralError(format!(
+ "Invalid delete_file_index: {} (pool size: {})",
+ idx,
+ delete_file_pool.len()
+ ))
})
})
.collect::<Result<Vec<_>, ExecutionError>>()
@@ -3756,14 +3766,15 @@ fn parse_file_scan_tasks_from_common(
proto_common
.residual_pool
.get(idx as usize)
- .and_then(convert_spark_expr_to_predicate)
+ .and_then(iceberg_predicate_to_predicate)
.and_then(|pred| {
// The residual predicate only drives row-group
pruning; the post-scan
// filter still enforces correctness. iceberg-rust
cannot bind a datum
// whose type has no conversion to the column type, so
on a bind failure
- // we skip pushdown rather than fail the scan,
mirroring the NOT IN
- // handling above.
- match pred.bind(Arc::clone(&schema_ref), true) {
+ // we skip pushdown rather than fail the scan.
+ // rewrite_not first: iceberg-rust's scan evaluators
require negation-normal
+ // form and reject a bare NOT (e.g. from a `NOT (a =
b)` residual).
+ match pred.rewrite_not().bind(Arc::clone(&schema_ref),
true) {
Ok(bound) => Some(bound),
Err(e) => {
log::warn!("Skipping Iceberg predicate
pushdown; bind failed: {e}");
@@ -4093,246 +4104,129 @@ fn literal_to_array_ref(
}
// ============================================================================
-// Spark Expression to Iceberg Predicate Conversion
+// Iceberg Residual Predicate Conversion
// ============================================================================
-//
-// Predicates are converted through Spark expressions rather than directly from
-// Iceberg Java to Iceberg Rust. This leverages Comet's existing expression
-// serialization infrastructure, which handles hundreds of expression types.
-//
-// Conversion path:
-// Iceberg Expression (Java) -> Spark Catalyst Expression -> Protobuf ->
Iceberg Predicate (Rust)
-//
-// Note: NOT IN predicates are skipped because iceberg-rust's
RowGroupMetricsEvaluator::not_in()
-// always returns MIGHT_MATCH (never prunes row groups). These are handled by
CometFilter post-scan.
-/// Converts a protobuf Spark expression to an Iceberg predicate for row-group
filtering.
-fn convert_spark_expr_to_predicate(
- expr: &spark_expression::Expr,
+/// Converts a serialized Iceberg residual predicate into an iceberg-rust
`Predicate` for row-group
+/// pruning. This is only a pruning hint -- the post-scan CometFilter enforces
correctness -- so any
+/// node or literal that cannot be represented degrades to `None` (no
pushdown) rather than an
+/// error. Mirrors the proto 1:1 onto iceberg-rust's `Reference` builders.
+fn iceberg_predicate_to_predicate(
+ proto: &spark_operator::IcebergPredicate,
) -> Option<iceberg::expr::Predicate> {
- use spark_expression::expr::ExprStruct;
-
- match &expr.expr_struct {
- Some(ExprStruct::Eq(binary)) => convert_binary_to_predicate(
- &binary.left,
- &binary.right,
- iceberg::expr::PredicateOperator::Eq,
- ),
- Some(ExprStruct::Neq(binary)) => convert_binary_to_predicate(
- &binary.left,
- &binary.right,
- iceberg::expr::PredicateOperator::NotEq,
- ),
- Some(ExprStruct::Lt(binary)) => convert_binary_to_predicate(
- &binary.left,
- &binary.right,
- iceberg::expr::PredicateOperator::LessThan,
- ),
- Some(ExprStruct::LtEq(binary)) => convert_binary_to_predicate(
- &binary.left,
- &binary.right,
- iceberg::expr::PredicateOperator::LessThanOrEq,
- ),
- Some(ExprStruct::Gt(binary)) => convert_binary_to_predicate(
- &binary.left,
- &binary.right,
- iceberg::expr::PredicateOperator::GreaterThan,
- ),
- Some(ExprStruct::GtEq(binary)) => convert_binary_to_predicate(
- &binary.left,
- &binary.right,
- iceberg::expr::PredicateOperator::GreaterThanOrEq,
- ),
- Some(ExprStruct::IsNull(unary)) => {
- if let Some(ref child) = unary.child {
- extract_column_reference(child).map(|column| {
-
iceberg::expr::Predicate::Unary(iceberg::expr::UnaryExpression::new(
- iceberg::expr::PredicateOperator::IsNull,
- iceberg::expr::Reference::new(column),
- ))
- })
- } else {
- None
- }
- }
- Some(ExprStruct::IsNotNull(unary)) => {
- if let Some(ref child) = unary.child {
- extract_column_reference(child).map(|column| {
-
iceberg::expr::Predicate::Unary(iceberg::expr::UnaryExpression::new(
- iceberg::expr::PredicateOperator::NotNull,
- iceberg::expr::Reference::new(column),
- ))
- })
- } else {
- None
- }
- }
- Some(ExprStruct::And(binary)) => {
- let left = binary
- .left
- .as_ref()
- .and_then(|e| convert_spark_expr_to_predicate(e));
- let right = binary
- .right
- .as_ref()
- .and_then(|e| convert_spark_expr_to_predicate(e));
- match (left, right) {
- (Some(l), Some(r)) => Some(l.and(r)),
- (Some(l), None) => Some(l),
- (None, Some(r)) => Some(r),
+ use iceberg::expr::{Predicate, Reference};
+ use spark_operator::iceberg_predicate::Node;
+ use spark_operator::IcebergPredicateOperator as Op;
+
+ match proto.node.as_ref()? {
+ Node::Unary(u) => {
+ let column = Reference::new(u.column.clone());
+ match u.op() {
+ Op::IsNull => Some(column.is_null()),
+ Op::NotNull => Some(column.is_not_null()),
+ Op::IsNan => Some(column.is_nan()),
+ Op::NotNan => Some(column.is_not_nan()),
_ => None,
}
}
- Some(ExprStruct::Or(binary)) => {
- let left = binary
- .left
- .as_ref()
- .and_then(|e| convert_spark_expr_to_predicate(e));
- let right = binary
- .right
- .as_ref()
- .and_then(|e| convert_spark_expr_to_predicate(e));
- match (left, right) {
- (Some(l), Some(r)) => Some(l.or(r)),
- _ => None, // OR requires both sides to be valid
+ Node::Binary(b) => {
+ let column = Reference::new(b.column.clone());
+ let datum = iceberg_literal_to_datum(b.value.as_ref()?)?;
+ match b.op() {
+ Op::Eq => Some(column.equal_to(datum)),
+ Op::NotEq => Some(column.not_equal_to(datum)),
+ Op::LessThan => Some(column.less_than(datum)),
+ Op::LessThanOrEq => Some(column.less_than_or_equal_to(datum)),
+ Op::GreaterThan => Some(column.greater_than(datum)),
+ Op::GreaterThanOrEq =>
Some(column.greater_than_or_equal_to(datum)),
+ Op::StartsWith => Some(column.starts_with(datum)),
+ Op::NotStartsWith => Some(column.not_starts_with(datum)),
+ _ => None,
}
}
- Some(ExprStruct::Not(unary)) => unary
- .child
- .as_ref()
- .and_then(|child| convert_spark_expr_to_predicate(child))
- .map(|p| !p),
- Some(ExprStruct::In(in_expr)) => {
- // NOT IN predicates don't work correctly with iceberg-rust's
row-group filtering.
- // The iceberg-rust RowGroupMetricsEvaluator::not_in() always
returns MIGHT_MATCH
- // (never prunes row groups), even in cases where pruning is
possible (e.g., when
- // min == max == value and value is in the NOT IN set).
- //
- // Workaround: Skip NOT IN in predicate pushdown and let
CometFilter handle it
- // post-scan. This sacrifices row-group pruning for NOT IN but
ensures correctness.
- if in_expr.negated {
+ Node::Set(s) => {
+ // Only IN prunes from stats; not_in is inherently unprunable, so
it is never emitted.
+ if s.op() != Op::In {
return None;
}
-
- if let Some(ref value) = in_expr.in_value {
- if let Some(column) = extract_column_reference(value) {
- let datums: Vec<iceberg::spec::Datum> = in_expr
- .lists
- .iter()
- .filter_map(extract_literal_as_datum)
- .collect();
-
- if datums.len() == in_expr.lists.len() {
-
Some(iceberg::expr::Reference::new(column).is_in(datums))
- } else {
- None
- }
- } else {
- None
- }
- } else {
- None
- }
+ let column = Reference::new(s.column.clone());
+ let datums = s
+ .values
+ .iter()
+ .map(iceberg_literal_to_datum)
+ .collect::<Option<Vec<_>>>()?;
+ Some(column.is_in(datums))
+ }
+ // And/Or are whole-or-nothing: both children must convert. Dropping a
child is unsafe.
+ // A dropped disjunct strengthens an Or directly; a dropped conjunct
is safe for a bare And
+ // but not under a NOT, where De Morgan turns And into Or (`NOT(A AND
B)` = `NOT A OR NOT B`)
+ // and dropping strengthens it, wrongly pruning row groups. Since Not
is only pushed to
+ // negation-normal form after decode (rewrite_not below), the
Not(And(..)) shape does reach
+ // here, so the And arm cannot assume positive position. Degrading to
no-pushdown is always
+ // safe; the post-scan CometFilter is exact. See `combine_logical` for
the parity invariant.
+ Node::And(l) => {
+ let left =
l.left.as_deref().and_then(iceberg_predicate_to_predicate);
+ let right =
l.right.as_deref().and_then(iceberg_predicate_to_predicate);
+ combine_logical(left, right, Predicate::and)
}
- _ => None, // Unsupported expression
+ Node::Or(o) => {
+ let left =
o.left.as_deref().and_then(iceberg_predicate_to_predicate);
+ let right =
o.right.as_deref().and_then(iceberg_predicate_to_predicate);
+ combine_logical(left, right, Predicate::or)
+ }
+ Node::Not(child) => iceberg_predicate_to_predicate(child).map(|p| !p),
}
}
-fn convert_binary_to_predicate(
- left: &Option<Box<spark_expression::Expr>>,
- right: &Option<Box<spark_expression::Expr>>,
- op: iceberg::expr::PredicateOperator,
+/// Combines the two children of a logical residual node (And/Or), returning
`None` unless both
+/// converted. A missing child is not expected: the Scala serde emits a
logical node only when both
+/// children convert, and Rust decodes every node it emits, so both sides
always convert for a
+/// well-formed residual. A `None` here therefore means the two serde paths
have diverged (e.g. a
+/// new `IcebergLiteral` variant on the serialize side without a matching
decode arm), so it trips a
+/// debug-only assertion and degrades to no-pushdown in release. See
[`iceberg_predicate_to_predicate`]
+/// for why a partial logical node must never be pushed.
+fn combine_logical(
+ left: Option<iceberg::expr::Predicate>,
+ right: Option<iceberg::expr::Predicate>,
+ combine: fn(iceberg::expr::Predicate, iceberg::expr::Predicate) ->
iceberg::expr::Predicate,
) -> Option<iceberg::expr::Predicate> {
- let left_ref = left.as_ref()?;
- let right_ref = right.as_ref()?;
-
- if let (Some(column), Some(datum)) = (
- extract_column_reference(left_ref),
- extract_literal_as_datum(right_ref),
- ) {
- return Some(iceberg::expr::Predicate::Binary(
- iceberg::expr::BinaryExpression::new(op,
iceberg::expr::Reference::new(column), datum),
- ));
- }
-
- if let (Some(datum), Some(column)) = (
- extract_literal_as_datum(left_ref),
- extract_column_reference(right_ref),
- ) {
- let reversed_op = match op {
- iceberg::expr::PredicateOperator::LessThan => {
- iceberg::expr::PredicateOperator::GreaterThan
- }
- iceberg::expr::PredicateOperator::LessThanOrEq => {
- iceberg::expr::PredicateOperator::GreaterThanOrEq
- }
- iceberg::expr::PredicateOperator::GreaterThan => {
- iceberg::expr::PredicateOperator::LessThan
- }
- iceberg::expr::PredicateOperator::GreaterThanOrEq => {
- iceberg::expr::PredicateOperator::LessThanOrEq
- }
- _ => op, // Eq and NotEq are symmetric
- };
- return Some(iceberg::expr::Predicate::Binary(
- iceberg::expr::BinaryExpression::new(
- reversed_op,
- iceberg::expr::Reference::new(column),
- datum,
- ),
- ));
+ match (left, right) {
+ (Some(l), Some(r)) => Some(combine(l, r)),
+ _ => {
+ debug_assert!(
+ false,
+ "Iceberg residual logical node with an unconvertible child:
the Scala serde emits \
+ both children and Rust decodes every node it emits, so the
two serde paths have \
+ diverged"
+ );
+ None
+ }
}
-
- None
}
-fn extract_column_reference(expr: &spark_expression::Expr) -> Option<String> {
- use spark_expression::expr::ExprStruct;
+/// Converts a serialized `IcebergLiteral` into an iceberg-rust `Datum` for
predicate pushdown.
+/// Returns `None` for null and for byte-array-backed types
(decimal/uuid/fixed/binary), which
+/// iceberg-rust cannot use in the page index yet; the driver does not emit
those for predicates,
+/// so this only guards against unexpected input. Correctness is unaffected --
a dropped literal
+/// just skips pushdown.
+fn iceberg_literal_to_datum(lit: &spark_operator::IcebergLiteral) ->
Option<iceberg::spec::Datum> {
+ use spark_operator::iceberg_literal::Value;
- match &expr.expr_struct {
- Some(ExprStruct::Unbound(unbound_ref)) =>
Some(unbound_ref.name.clone()),
- _ => None,
+ if lit.is_null {
+ return None;
}
-}
-
-fn extract_literal_as_datum(expr: &spark_expression::Expr) ->
Option<iceberg::spec::Datum> {
- use spark_expression::expr::ExprStruct;
- match &expr.expr_struct {
- Some(ExprStruct::Literal(literal)) => {
- if literal.is_null {
- return None;
- }
-
- match &literal.value {
- Some(spark_expression::literal::Value::IntVal(v)) => {
- Some(iceberg::spec::Datum::int(*v))
- }
- Some(spark_expression::literal::Value::LongVal(v)) => {
- Some(iceberg::spec::Datum::long(*v))
- }
- Some(spark_expression::literal::Value::FloatVal(v)) => {
- Some(iceberg::spec::Datum::double(*v as f64))
- }
- Some(spark_expression::literal::Value::DoubleVal(v)) => {
- Some(iceberg::spec::Datum::double(*v))
- }
- Some(spark_expression::literal::Value::StringVal(v)) => {
- Some(iceberg::spec::Datum::string(v.clone()))
- }
- Some(spark_expression::literal::Value::BoolVal(v)) => {
- Some(iceberg::spec::Datum::bool(*v))
- }
- Some(spark_expression::literal::Value::ByteVal(v)) => {
- Some(iceberg::spec::Datum::int(*v))
- }
- Some(spark_expression::literal::Value::ShortVal(v)) => {
- Some(iceberg::spec::Datum::int(*v))
- }
- _ => None,
- }
- }
- _ => None,
+ match lit.value.as_ref()? {
+ Value::BoolVal(v) => Some(iceberg::spec::Datum::bool(*v)),
+ Value::IntVal(v) => Some(iceberg::spec::Datum::int(*v)),
+ Value::LongVal(v) => Some(iceberg::spec::Datum::long(*v)),
+ Value::FloatVal(v) => Some(iceberg::spec::Datum::float(*v)),
+ Value::DoubleVal(v) => Some(iceberg::spec::Datum::double(*v)),
+ Value::DateVal(v) => Some(iceberg::spec::Datum::date(*v)),
+ Value::TimestampVal(v) =>
Some(iceberg::spec::Datum::timestamp_micros(*v)),
+ Value::TimestampTzVal(v) =>
Some(iceberg::spec::Datum::timestamptz_micros(*v)),
+ Value::StringVal(v) => Some(iceberg::spec::Datum::string(v.clone())),
+ Value::DecimalVal(_) | Value::UuidVal(_) | Value::FixedVal(_) |
Value::BinaryVal(_) => None,
}
}
diff --git a/native/proto/src/proto/operator.proto
b/native/proto/src/proto/operator.proto
index d00979f0a0..439c9613a4 100644
--- a/native/proto/src/proto/operator.proto
+++ b/native/proto/src/proto/operator.proto
@@ -161,25 +161,43 @@ message CsvOptions {
bool truncated_rows = 8;
}
+// A typed Iceberg primitive value, shared by partition values and predicate
literals. The oneof
+// case is the type tag (iceberg-rust's Datum is self-typed and
Predicate::bind does not coerce, so
+// the type must be known at construction). Grows by adding a case as Iceberg
adds primitives
+// (GEOMETRY/VARIANT/nanosecond timestamps); unrecognized values decode to
no-op, never an error.
+message IcebergLiteral {
+ bool is_null = 1;
+ oneof value {
+ bool bool_val = 2;
+ int32 int_val = 3;
+ int64 long_val = 4;
+ float float_val = 5;
+ double double_val = 6;
+ int32 date_val = 7; // days since epoch
+ int64 timestamp_val = 8; // micros since epoch, no tz
+ int64 timestamp_tz_val = 9; // micros since epoch, utc
+ string string_val = 10;
+ bytes uuid_val = 11;
+ bytes fixed_val = 12;
+ bytes binary_val = 13;
+ IcebergDecimal decimal_val = 14;
+ }
+}
+
+// A decimal literal. `scale`/`precision` let the native side build a
self-typed Datum for
+// predicate pushdown (iceberg-rust's Datum is self-typed); partition-value
decode reads only
+// `unscaled`. Predicate decimal pushdown is deferred (iceberg-rust page-index
gap), so today only
+// `unscaled` is consumed.
+message IcebergDecimal {
+ bytes unscaled = 1; // unscaled value, two's-complement big-endian
+ int32 precision = 2;
+ int32 scale = 3;
+}
+
// Partition value for Iceberg partition data
message PartitionValue {
int32 field_id = 1;
- oneof value {
- int32 int_val = 2;
- int64 long_val = 3;
- int64 date_val = 4; // days since epoch
- int64 timestamp_val = 5; // microseconds since epoch
- int64 timestamp_tz_val = 6; // microseconds with timezone
- string string_val = 7;
- double double_val = 8;
- float float_val = 9;
- bytes decimal_val = 10; // unscaled BigInteger bytes
- bool bool_val = 11;
- bytes uuid_val = 12;
- bytes fixed_val = 13;
- bytes binary_val = 14;
- }
- bool is_null = 15;
+ IcebergLiteral literal = 2;
}
// Collection of partition values for a single partition
@@ -187,6 +205,48 @@ message PartitionData {
repeated PartitionValue values = 1;
}
+// Iceberg residual predicate for row-group pruning, mirroring
iceberg::expr::Predicate. Only a
+// pruning hint: the post-scan CometFilter enforces correctness, so an
unrepresentable predicate is
+// simply omitted. Carries no expr_id/query_context (those are for native ANSI
error reporting on
+// executable expressions; a residual never errors), which also lets identical
residuals dedup.
+message IcebergPredicate {
+ oneof node {
+ IcebergUnaryPredicate unary = 1; // IS_NULL / NOT_NULL / IS_NAN / NOT_NAN
+ IcebergBinaryPredicate binary = 2; // column OP literal
+ IcebergSetPredicate set = 3; // IN only emitted; not_in is
inherently unprunable from stats
+ IcebergLogicalPredicate and = 4;
+ IcebergLogicalPredicate or = 5;
+ IcebergPredicate not = 6;
+ }
+}
+
+message IcebergUnaryPredicate { string column = 1; IcebergPredicateOperator
op = 2; }
+message IcebergBinaryPredicate { string column = 1; IcebergPredicateOperator
op = 2; IcebergLiteral value = 3; }
+message IcebergSetPredicate { string column = 1; IcebergPredicateOperator
op = 2; repeated IcebergLiteral values = 3; }
+message IcebergLogicalPredicate { IcebergPredicate left = 1; IcebergPredicate
right = 2; }
+
+// Mirrors iceberg-rust PredicateOperator (crates/iceberg/src/expr/mod.rs) so
the mapping on both
+// sides is 1:1. The driver emits a subset by design: NOT_IN never prunes from
column stats
+// (inherent, matches Iceberg-Java), and IS_NAN/STARTS_WITH are not produced
by the current residual
+// path. Unspecified/unrecognized decodes to no-pushdown.
+enum IcebergPredicateOperator {
+ Unspecified = 0;
+ IsNull = 1;
+ NotNull = 2;
+ IsNan = 3;
+ NotNan = 4;
+ LessThan = 5;
+ LessThanOrEq = 6;
+ GreaterThan = 7;
+ GreaterThanOrEq = 8;
+ Eq = 9;
+ NotEq = 10;
+ StartsWith = 11;
+ NotStartsWith = 12;
+ In = 13;
+ NotIn = 14;
+}
+
// Common data shared by all partitions in split mode (sent once, captured in
closure)
message IcebergScanCommon {
// Catalog-specific configuration for FileIO (credentials, S3/GCS config,
etc.)
@@ -206,7 +266,7 @@ message IcebergScanCommon {
repeated ProjectFieldIdList project_field_ids_pool = 8;
repeated PartitionData partition_data_pool = 9;
repeated DeleteFileList delete_files_pool = 10;
- repeated spark.spark_expression.Expr residual_pool = 11;
+ repeated IcebergPredicate residual_pool = 11;
// Number of data files to read concurrently within a single task
uint32 data_file_concurrency_limit = 12;
@@ -216,6 +276,12 @@ message IcebergScanCommon {
// isolated provider instances. Empty string when the table has no catalog
identity (e.g.
// HadoopTables loaded by raw path).
string catalog_name = 13;
+
+ // Flat pool of unique delete files, referenced by index from
DeleteFileList. Iceberg's default
+ // partition delete granularity has one delete file apply to many data
files, so interning each
+ // delete file here (rather than embedding copies per delete_files_pool set)
keeps the broadcast
+ // common message from growing with the number of references.
+ repeated IcebergDeleteFile delete_file_pool = 14;
}
message IcebergScan {
@@ -233,7 +299,8 @@ message ProjectFieldIdList {
// Helper message for deduplicating delete file lists
message DeleteFileList {
- repeated IcebergDeleteFile delete_files = 1;
+ // Indices into IcebergScanCommon.delete_file_pool
+ repeated uint32 delete_file_indices = 1;
}
// Iceberg FileScanTask containing data file, delete files, and residual filter
diff --git
a/spark/src/main/scala/org/apache/comet/iceberg/IcebergReflection.scala
b/spark/src/main/scala/org/apache/comet/iceberg/IcebergReflection.scala
index 9f48ddfa9e..1a5cea7c5c 100644
--- a/spark/src/main/scala/org/apache/comet/iceberg/IcebergReflection.scala
+++ b/spark/src/main/scala/org/apache/comet/iceberg/IcebergReflection.scala
@@ -674,6 +674,42 @@ object IcebergReflection extends Logging {
}
}
+ /**
+ * Top-level column names whose Iceberg type iceberg-rust's page-index
evaluator cannot prune
+ * over, so callers must not push a residual predicate on them, not even the
IS NOT NULL that
+ * Iceberg adds for every filtered column. Two physical layouts fail
(page_index_evaluator.rs):
+ * - FIXED_LEN_BYTE_ARRAY (decimal, uuid, fixed): rejected outright as an
unsupported index
+ * type, which fails the native scan.
+ * - BYTE_ARRAY backing a binary column: the evaluator decodes
column-index min/max as UTF-8
+ * (String::from_utf8(..).unwrap()) before the predicate closure runs,
so non-UTF-8 bounds
+ * panic the native scan even for a bare IS [NOT] NULL. Extend this set
as Iceberg adds
+ * types with either layout (e.g. geometry).
+ */
+ def pageIndexUnsupportedColumns(schema: Any): Set[String] = {
+ import scala.jdk.CollectionConverters._
+ try {
+ val columns = schema.getClass
+ .getMethod("columns")
+ .invoke(schema)
+ .asInstanceOf[java.util.List[_]]
+ columns.asScala.flatMap { column =>
+ val name =
column.getClass.getMethod("name").invoke(column).asInstanceOf[String]
+ val typeStr = column.getClass.getMethod("type").invoke(column).toString
+ if (typeStr.startsWith("decimal(") || typeStr == "uuid" ||
typeStr.startsWith("fixed[") ||
+ typeStr == "binary") {
+ Some(name)
+ } else {
+ None
+ }
+ }.toSet
+ } catch {
+ case e: Exception =>
+ logWarning(
+ s"Failed to inspect schema for page-index-unsupported columns:
${e.getMessage}")
+ Set.empty[String]
+ }
+ }
+
/**
* Validates partition column types for compatibility with iceberg-rust.
*
diff --git
a/spark/src/main/scala/org/apache/comet/serde/operator/CometIcebergNativeScan.scala
b/spark/src/main/scala/org/apache/comet/serde/operator/CometIcebergNativeScan.scala
index 879762818f..247a2f9245 100644
---
a/spark/src/main/scala/org/apache/comet/serde/operator/CometIcebergNativeScan.scala
+++
b/spark/src/main/scala/org/apache/comet/serde/operator/CometIcebergNativeScan.scala
@@ -19,6 +19,11 @@
package org.apache.comet.serde.operator
+import java.math.BigDecimal
+import java.nio.ByteBuffer
+import java.nio.charset.StandardCharsets.UTF_8
+import java.util.{Base64, UUID}
+
import scala.collection.mutable
import scala.jdk.CollectionConverters._
@@ -32,12 +37,13 @@ import
org.apache.spark.sql.comet.shims.ShimDataSourceRDDPartition
import org.apache.spark.sql.execution.datasources.v2.{BatchScanExec,
DataSourceRDD, DataSourceRDDPartition}
import org.apache.spark.sql.types._
+import com.google.protobuf.ByteString
+
import org.apache.comet.{CometConf, ConfigEntry}
import org.apache.comet.iceberg.{CometIcebergNativeScanMetadata,
IcebergReflection}
import org.apache.comet.serde.{CometOperatorSerde, OperatorOuterClass}
-import org.apache.comet.serde.ExprOuterClass.Expr
import org.apache.comet.serde.OperatorOuterClass.{Operator, SparkStructField}
-import org.apache.comet.serde.QueryPlanSerde.{exprToProto, serializeDataType}
+import org.apache.comet.serde.QueryPlanSerde.serializeDataType
object CometIcebergNativeScan extends CometOperatorSerde[CometBatchScanExec]
with Logging {
@@ -59,7 +65,6 @@ object CometIcebergNativeScan extends
CometOperatorSerde[CometBatchScanExec] wit
val GT = "GT"
val GT_EQ = "GT_EQ"
val IN = "IN"
- val NOT_IN = "NOT_IN"
}
// Iceberg expression class name suffixes
@@ -71,22 +76,43 @@ object CometIcebergNativeScan extends
CometOperatorSerde[CometBatchScanExec] wit
}
}
+ /** Iceberg binary comparison operation names mapped to their
IcebergPredicate operator. */
+ private val binaryOps: Map[String,
OperatorOuterClass.IcebergPredicateOperator] = Map(
+ Constants.Operations.EQ -> OperatorOuterClass.IcebergPredicateOperator.Eq,
+ Constants.Operations.NOT_EQ ->
OperatorOuterClass.IcebergPredicateOperator.NotEq,
+ Constants.Operations.LT ->
OperatorOuterClass.IcebergPredicateOperator.LessThan,
+ Constants.Operations.LT_EQ ->
OperatorOuterClass.IcebergPredicateOperator.LessThanOrEq,
+ Constants.Operations.GT ->
OperatorOuterClass.IcebergPredicateOperator.GreaterThan,
+ Constants.Operations.GT_EQ ->
OperatorOuterClass.IcebergPredicateOperator.GreaterThanOrEq)
+
/**
- * Converts an Iceberg partition value to protobuf format. Protobuf is less
verbose than JSON.
- * The following types are also serialized as integer values instead of as
strings - Timestamps,
- * Dates, Decimals, FieldIDs
+ * Wraps an Iceberg partition value (a typed primitive) in a PartitionValue.
The value encoding
+ * is shared with predicate literals via [[icebergLiteralToProto]].
*/
private def partitionValueToProto(
fieldId: Int,
fieldTypeStr: String,
- value: Any): OperatorOuterClass.PartitionValue = {
- val builder = OperatorOuterClass.PartitionValue.newBuilder()
- builder.setFieldId(fieldId)
+ value: Any): OperatorOuterClass.PartitionValue =
+ OperatorOuterClass.PartitionValue
+ .newBuilder()
+ .setFieldId(fieldId)
+ .setLiteral(icebergLiteralToProto(fieldTypeStr, value))
+ .build()
+
+ /**
+ * Converts an Iceberg primitive value to a typed IcebergLiteral, keyed on
the Iceberg type
+ * name. Protobuf is less verbose than JSON; timestamps/dates/decimals are
serialized as their
+ * integer or byte encodings rather than strings. Shared by partition values
and predicate
+ * literals.
+ */
+ private def icebergLiteralToProto(
+ fieldTypeStr: String,
+ value: Any): OperatorOuterClass.IcebergLiteral = {
+ val builder = OperatorOuterClass.IcebergLiteral.newBuilder()
if (value == null) {
builder.setIsNull(true)
} else {
- builder.setIsNull(false)
fieldTypeStr match {
case t if t.startsWith("timestamp") =>
val micros = value match {
@@ -105,13 +131,17 @@ object CometIcebergNativeScan extends
CometOperatorSerde[CometBatchScanExec] wit
builder.setDateVal(days)
case d if d.startsWith("decimal(") =>
- // Serialize as unscaled BigInteger bytes
val bigDecimal = value match {
- case bd: java.math.BigDecimal => bd
- case _ => new java.math.BigDecimal(value.toString)
+ case bd: BigDecimal => bd
+ case _ => new BigDecimal(value.toString)
}
- val unscaledBytes = bigDecimal.unscaledValue().toByteArray
-
builder.setDecimalVal(com.google.protobuf.ByteString.copyFrom(unscaledBytes))
+ builder.setDecimalVal(
+ OperatorOuterClass.IcebergDecimal
+ .newBuilder()
+
.setUnscaled(ByteString.copyFrom(bigDecimal.unscaledValue().toByteArray))
+ .setPrecision(bigDecimal.precision())
+ .setScale(bigDecimal.scale())
+ .build())
case "string" =>
builder.setStringVal(value.toString)
@@ -158,20 +188,20 @@ object CometIcebergNativeScan extends
CometOperatorSerde[CometBatchScanExec] wit
case "uuid" =>
// UUID as bytes (16 bytes) or string
val uuidBytes = value match {
- case uuid: java.util.UUID =>
- val bb = java.nio.ByteBuffer.wrap(new Array[Byte](16))
+ case uuid: UUID =>
+ val bb = ByteBuffer.wrap(new Array[Byte](16))
bb.putLong(uuid.getMostSignificantBits)
bb.putLong(uuid.getLeastSignificantBits)
bb.array()
case _ =>
// Parse UUID string and convert to bytes
- val uuid = java.util.UUID.fromString(value.toString)
- val bb = java.nio.ByteBuffer.wrap(new Array[Byte](16))
+ val uuid = UUID.fromString(value.toString)
+ val bb = ByteBuffer.wrap(new Array[Byte](16))
bb.putLong(uuid.getMostSignificantBits)
bb.putLong(uuid.getLeastSignificantBits)
bb.array()
}
-
builder.setUuidVal(com.google.protobuf.ByteString.copyFrom(uuidBytes))
+ builder.setUuidVal(ByteString.copyFrom(uuidBytes))
case t if t.startsWith("fixed[") || t.startsWith("binary") =>
val bytes = value match {
@@ -179,9 +209,9 @@ object CometIcebergNativeScan extends
CometOperatorSerde[CometBatchScanExec] wit
case _ => value.toString.getBytes("UTF-8")
}
if (t.startsWith("fixed")) {
- builder.setFixedVal(com.google.protobuf.ByteString.copyFrom(bytes))
+ builder.setFixedVal(ByteString.copyFrom(bytes))
} else {
-
builder.setBinaryVal(com.google.protobuf.ByteString.copyFrom(bytes))
+ builder.setBinaryVal(ByteString.copyFrom(bytes))
}
// Fallback: infer type from Java type ?
@@ -201,24 +231,6 @@ object CometIcebergNativeScan extends
CometOperatorSerde[CometBatchScanExec] wit
builder.build()
}
- /**
- * Helper to extract a literal from an Iceberg expression and build a binary
predicate.
- */
- private def buildBinaryPredicate(
- exprClass: Class[_],
- icebergExpr: Any,
- attribute: Attribute,
- builder: (Expression, Expression) => Expression): Option[Expression] = {
- try {
- val literalMethod = exprClass.getMethod("literal")
- val literal = literalMethod.invoke(icebergExpr)
- val value = convertIcebergLiteral(literal, attribute.dataType)
- Some(builder(attribute, value))
- } catch {
- case _: Exception => None
- }
- }
-
/**
* Extracts delete files from an Iceberg FileScanTask as a list (for
deduplication).
*
@@ -462,7 +474,7 @@ object CometIcebergNativeScan extends
CometOperatorSerde[CometBatchScanExec] wit
// Deduplicate by protobuf bytes (use Base64 string as key)
val partitionDataBytes = partitionDataProto.toByteArray
- val partitionDataKey =
java.util.Base64.getEncoder.encodeToString(partitionDataBytes)
+ val partitionDataKey =
Base64.getEncoder.encodeToString(partitionDataBytes)
val partitionDataIdx = partitionDataToPoolIndex.getOrElseUpdate(
partitionDataKey, {
@@ -532,137 +544,216 @@ object CometIcebergNativeScan extends
CometOperatorSerde[CometBatchScanExec] wit
}
/**
- * Converts Iceberg Expression objects to Spark Catalyst expressions.
- *
- * This is used to extract per-file residual expressions from Iceberg
FileScanTasks. Residuals
- * are created by Iceberg's ResidualEvaluator through partial evaluation of
scan filters against
- * each file's partition data. These residuals enable row-group level
filtering in the Parquet
- * reader.
+ * Converts an Iceberg residual Expression into an IcebergPredicate for
native row-group
+ * pruning.
*
- * The conversion uses reflection because Iceberg expressions are not
directly accessible from
- * Spark's classpath during query planning.
+ * Residuals come from Iceberg's ResidualEvaluator (partial evaluation of
the scan filter
+ * against each file's partition data). This is only a pruning hint: the
CometFilter above the
+ * scan enforces correctness, so any node or literal we cannot represent
yields None (no
+ * pushdown). Predicates over `pageIndexUnsupportedColumns` also yield None
(iceberg-rust cannot
+ * use those columns in the page index). Uses reflection because Iceberg's
expression classes
+ * are not on Spark's classpath at planning time; residuals are unbound
predicates carrying a
+ * NamedReference (column name) and a literal.
*/
- def convertIcebergExpression(icebergExpr: Any, output: Seq[Attribute]):
Option[Expression] = {
+ def icebergExprToProto(
+ icebergExpr: Any,
+ output: Seq[Attribute],
+ pageIndexUnsupportedColumns: Set[String]):
Option[OperatorOuterClass.IcebergPredicate] = {
try {
val exprClass = icebergExpr.getClass
val attributeMap = output.map(attr => attr.name -> attr).toMap
- // Check for UnboundPredicate
if
(exprClass.getName.endsWith(Constants.ExpressionTypes.UNBOUND_PREDICATE)) {
- val opMethod = exprClass.getMethod("op")
- val termMethod = exprClass.getMethod("term")
- val operation = opMethod.invoke(icebergExpr)
- val term = termMethod.invoke(icebergExpr)
-
- // Get column name from term
- val refMethod = term.getClass.getMethod("ref")
- val ref = refMethod.invoke(term)
- val nameMethod = ref.getClass.getMethod("name")
- val columnName = nameMethod.invoke(ref).asInstanceOf[String]
-
- val attr = attributeMap.get(columnName)
-
- val opName = operation.toString
-
- attr.flatMap { attribute =>
- opName match {
- case Constants.Operations.IS_NULL =>
- Some(IsNull(attribute))
-
- case Constants.Operations.IS_NOT_NULL |
Constants.Operations.NOT_NULL =>
- Some(IsNotNull(attribute))
-
- case Constants.Operations.EQ =>
- buildBinaryPredicate(exprClass, icebergExpr, attribute, EqualTo)
-
- case Constants.Operations.NOT_EQ =>
- buildBinaryPredicate(
- exprClass,
- icebergExpr,
- attribute,
- (a, v) => Not(EqualTo(a, v)))
-
- case Constants.Operations.LT =>
- buildBinaryPredicate(exprClass, icebergExpr, attribute, LessThan)
-
- case Constants.Operations.LT_EQ =>
- buildBinaryPredicate(exprClass, icebergExpr, attribute,
LessThanOrEqual)
-
- case Constants.Operations.GT =>
- buildBinaryPredicate(exprClass, icebergExpr, attribute,
GreaterThan)
-
- case Constants.Operations.GT_EQ =>
- buildBinaryPredicate(exprClass, icebergExpr, attribute,
GreaterThanOrEqual)
-
- case Constants.Operations.IN =>
- val literalsMethod = exprClass.getMethod("literals")
- val literals =
literalsMethod.invoke(icebergExpr).asInstanceOf[java.util.List[_]]
- val values =
- literals.asScala.map(lit => convertIcebergLiteral(lit,
attribute.dataType))
- Some(In(attribute, values.toSeq))
-
- case Constants.Operations.NOT_IN =>
- val literalsMethod = exprClass.getMethod("literals")
- val literals =
literalsMethod.invoke(icebergExpr).asInstanceOf[java.util.List[_]]
- val values =
- literals.asScala.map(lit => convertIcebergLiteral(lit,
attribute.dataType))
- Some(Not(In(attribute, values.toSeq)))
-
- case _ =>
- None
+ val operation = exprClass.getMethod("op").invoke(icebergExpr).toString
+ val term = exprClass.getMethod("term").invoke(icebergExpr)
+ val ref = term.getClass.getMethod("ref").invoke(term)
+ val columnName =
ref.getClass.getMethod("name").invoke(ref).asInstanceOf[String]
+
+ // Iceberg names a nested reference by its dotted path
("struct.field"), which never matches
+ // a top-level scan output attribute, so a residual on a nested field
drops here. That miss
+ // is also why the top-level-only pageIndexUnsupportedColumns gate
below stays sound.
+ attributeMap.get(columnName).flatMap { attribute =>
+ import Constants.Operations._
+ import OperatorOuterClass.IcebergPredicateOperator
+ if (pageIndexUnsupportedColumns.contains(columnName)) {
+ // Any predicate on this column, including a unary IS [NOT] NULL,
would reach the page
+ // index and fail, so drop the whole predicate; the post-scan
CometFilter enforces it.
+ None
+ } else {
+ operation match {
+ case IS_NULL => Some(unaryPredicate(columnName,
IcebergPredicateOperator.IsNull))
+ case IS_NOT_NULL | NOT_NULL =>
+ Some(unaryPredicate(columnName,
IcebergPredicateOperator.NotNull))
+ case op if binaryOps.contains(op) =>
+ binaryPredicate(exprClass, icebergExpr, columnName, attribute,
binaryOps(op))
+ case IN => setPredicate(exprClass, icebergExpr, columnName,
attribute)
+ // NOT_IN is inherently unprunable from column stats, so it is
not pushed.
+ case _ => None
+ }
}
}
} else if (exprClass.getName.endsWith(Constants.ExpressionTypes.AND)) {
- val leftMethod = exprClass.getMethod("left")
- val rightMethod = exprClass.getMethod("right")
- val left = leftMethod.invoke(icebergExpr)
- val right = rightMethod.invoke(icebergExpr)
-
- (convertIcebergExpression(left, output),
convertIcebergExpression(right, output)) match {
- case (Some(l), Some(r)) => Some(And(l, r))
+ val left = icebergExprToProto(
+ exprClass.getMethod("left").invoke(icebergExpr),
+ output,
+ pageIndexUnsupportedColumns)
+ val right = icebergExprToProto(
+ exprClass.getMethod("right").invoke(icebergExpr),
+ output,
+ pageIndexUnsupportedColumns)
+ (left, right) match {
+ // Push the residual only if it converts whole. Dropping a conjunct
is safe in positive
+ // position but strengthens the predicate under a NOT (De Morgan),
which would wrongly
+ // prune, and tracking polarity across arbitrary nesting is error
prone. So an
+ // unconvertible conjunct elides the whole residual; the post-scan
CometFilter is exact.
+ case (Some(l), Some(r)) => Some(logicalPredicate(isAnd = true, l, r))
case _ => None
}
} else if (exprClass.getName.endsWith(Constants.ExpressionTypes.OR)) {
- val leftMethod = exprClass.getMethod("left")
- val rightMethod = exprClass.getMethod("right")
- val left = leftMethod.invoke(icebergExpr)
- val right = rightMethod.invoke(icebergExpr)
-
- (convertIcebergExpression(left, output),
convertIcebergExpression(right, output)) match {
- case (Some(l), Some(r)) => Some(Or(l, r))
+ val left = icebergExprToProto(
+ exprClass.getMethod("left").invoke(icebergExpr),
+ output,
+ pageIndexUnsupportedColumns)
+ val right = icebergExprToProto(
+ exprClass.getMethod("right").invoke(icebergExpr),
+ output,
+ pageIndexUnsupportedColumns)
+ // Dropping a disjunct would strengthen the predicate and wrongly
prune, so require both.
+ (left, right) match {
+ case (Some(l), Some(r)) => Some(logicalPredicate(isAnd = false, l,
r))
case _ => None
}
} else if (exprClass.getName.endsWith(Constants.ExpressionTypes.NOT)) {
- val childMethod = exprClass.getMethod("child")
- val child = childMethod.invoke(icebergExpr)
-
- convertIcebergExpression(child, output).map(Not)
+ val child = exprClass.getMethod("child").invoke(icebergExpr)
+ icebergExprToProto(child, output,
pageIndexUnsupportedColumns).map(notPredicate)
} else {
None
}
} catch {
- case _: Exception =>
+ // Reflection over Iceberg's expression classes can fail on an
unexpected shape (e.g. an
+ // Iceberg version change). A residual is only a pruning hint, so skip
pushdown rather than
+ // fail the scan, but log it: a persistent warning here signals a real
API drift to fix.
+ case e: Exception =>
+ logWarning(
+ "Skipping Iceberg residual pushdown; could not convert expression: "
+
+ s"${e.getMessage}")
None
}
}
+ private def unaryPredicate(
+ column: String,
+ op: OperatorOuterClass.IcebergPredicateOperator):
OperatorOuterClass.IcebergPredicate =
+ OperatorOuterClass.IcebergPredicate
+ .newBuilder()
+
.setUnary(OperatorOuterClass.IcebergUnaryPredicate.newBuilder().setColumn(column).setOp(op))
+ .build()
+
+ private def binaryPredicate(
+ exprClass: Class[_],
+ icebergExpr: Any,
+ column: String,
+ attribute: Attribute,
+ op: OperatorOuterClass.IcebergPredicateOperator)
+ : Option[OperatorOuterClass.IcebergPredicate] = {
+ val literal = exprClass.getMethod("literal").invoke(icebergExpr)
+ predicateLiteralToProto(attribute.dataType,
icebergLiteralValue(literal)).map { lit =>
+ OperatorOuterClass.IcebergPredicate
+ .newBuilder()
+ .setBinary(
+ OperatorOuterClass.IcebergBinaryPredicate
+ .newBuilder()
+ .setColumn(column)
+ .setOp(op)
+ .setValue(lit))
+ .build()
+ }
+ }
+
+ private def setPredicate(
+ exprClass: Class[_],
+ icebergExpr: Any,
+ column: String,
+ attribute: Attribute): Option[OperatorOuterClass.IcebergPredicate] = {
+ val literals =
+
exprClass.getMethod("literals").invoke(icebergExpr).asInstanceOf[java.util.List[_]]
+ val protoLiterals =
+ literals.asScala.map(l =>
+ predicateLiteralToProto(attribute.dataType, icebergLiteralValue(l)))
+ if (protoLiterals.isEmpty || protoLiterals.exists(_.isEmpty)) {
+ None
+ } else {
+ val setBuilder = OperatorOuterClass.IcebergSetPredicate
+ .newBuilder()
+ .setColumn(column)
+ .setOp(OperatorOuterClass.IcebergPredicateOperator.In)
+ // Sort literals so an IN list that Iceberg happens to iterate in a
different order per file
+ // still deduplicates in the residual pool. Encode each key once (sortBy
re-invokes its key
+ // function per comparison).
+ protoLiterals.flatten
+ .map(lit => (Base64.getEncoder.encodeToString(lit.toByteArray), lit))
+ .sortBy(_._1)
+ .foreach { case (_, lit) => setBuilder.addValues(lit) }
+
Some(OperatorOuterClass.IcebergPredicate.newBuilder().setSet(setBuilder).build())
+ }
+ }
+
+ private def logicalPredicate(
+ isAnd: Boolean,
+ left: OperatorOuterClass.IcebergPredicate,
+ right: OperatorOuterClass.IcebergPredicate):
OperatorOuterClass.IcebergPredicate = {
+ val logical = OperatorOuterClass.IcebergLogicalPredicate
+ .newBuilder()
+ .setLeft(left)
+ .setRight(right)
+ val builder = OperatorOuterClass.IcebergPredicate.newBuilder()
+ if (isAnd) builder.setAnd(logical) else builder.setOr(logical)
+ builder.build()
+ }
+
+ private def notPredicate(
+ child: OperatorOuterClass.IcebergPredicate):
OperatorOuterClass.IcebergPredicate =
+ OperatorOuterClass.IcebergPredicate.newBuilder().setNot(child).build()
+
+ /** Extracts the raw Java value from an Iceberg Literal via reflection. */
+ private def icebergLiteralValue(icebergLiteral: Any): Any = {
+ val literalClass =
IcebergReflection.loadClass(IcebergReflection.ClassNames.LITERAL)
+ literalClass.getMethod("value").invoke(icebergLiteral)
+ }
+
/**
- * Converts an Iceberg Literal to a Spark Literal
+ * Builds a predicate literal from its Iceberg value and the column's Spark
type. Returns None
+ * for null (IS NULL is a separate op) and for Spark types this serde does
not map to an
+ * IcebergLiteral, so the predicate degrades to no pushdown rather than an
incorrect one.
+ * Columns backed by Parquet FIXED_LEN_BYTE_ARRAY (decimal/uuid/fixed) or
BYTE_ARRAY (binary)
+ * are dropped earlier by the column gate in icebergExprToProto and never
reach here.
*/
- private def convertIcebergLiteral(icebergLiteral: Any, sparkType: DataType):
Literal = {
- // Load Literal interface to get value() method (use interface to avoid
package-private issues)
- val literalClass =
IcebergReflection.loadClass(IcebergReflection.ClassNames.LITERAL)
- val valueMethod = literalClass.getMethod("value")
- val value = valueMethod.invoke(icebergLiteral)
-
- // Convert Java types to Spark internal types
- val sparkValue = (value, sparkType) match {
- case (s: String, _: StringType) =>
- org.apache.spark.unsafe.types.UTF8String.fromString(s)
- case (v, _) => v
+ private def predicateLiteralToProto(
+ sparkType: DataType,
+ value: Any): Option[OperatorOuterClass.IcebergLiteral] = {
+ if (value == null) {
+ return None
}
-
- Literal(sparkValue, sparkType)
+ val builder = OperatorOuterClass.IcebergLiteral.newBuilder()
+ sparkType match {
+ case _: BooleanType =>
builder.setBoolVal(value.asInstanceOf[java.lang.Boolean])
+ // Spark byte/short map to Iceberg int (32-bit); iceberg-rust has no
narrower integer type, so
+ // widening to int_val matches how it stores and prunes these columns.
+ case _: ByteType | _: ShortType | _: IntegerType =>
+ builder.setIntVal(value.asInstanceOf[java.lang.Number].intValue())
+ case _: LongType =>
builder.setLongVal(value.asInstanceOf[java.lang.Number].longValue())
+ case _: FloatType =>
builder.setFloatVal(value.asInstanceOf[java.lang.Number].floatValue())
+ case _: DoubleType =>
+
builder.setDoubleVal(value.asInstanceOf[java.lang.Number].doubleValue())
+ case _: DateType =>
builder.setDateVal(value.asInstanceOf[java.lang.Number].intValue())
+ case _: StringType => builder.setStringVal(value.toString)
+ case _: TimestampType =>
+
builder.setTimestampTzVal(value.asInstanceOf[java.lang.Number].longValue())
+ case _: TimestampNTZType =>
+
builder.setTimestampVal(value.asInstanceOf[java.lang.Number].longValue())
+ case _ => return None
+ }
+ Some(builder.build())
}
/**
@@ -704,6 +795,19 @@ object CometIcebergNativeScan extends
CometOperatorSerde[CometBatchScanExec] wit
* Called after doPrepare() has resolved DPP subqueries. Builds pools and
per-partition data in
* one pass from the DPP-filtered partitions.
*
+ * The result splits into a common block plus one block per Spark partition.
The common block
+ * holds everything shared across partitions and is broadcast once per
executor in the stage
+ * task binary; each per-partition block ships with its task. Protobuf
serializes by value and
+ * shares nothing between repeated fields, so shared values (schemas, specs,
partition data,
+ * residuals, delete files) are interned into pools in the common block and
referenced by index.
+ * Pooling also keeps a message under protobuf's 2 GiB limit
(getSerializedSize returns int and
+ * wraps past it), which matters for anything that scales with the number of
tasks.
+ *
+ * Delete files use two levels: a flat pool of unique files, plus a per-task
list of indices
+ * into it. One delete file applies to many data files under Iceberg's
default partition delete
+ * granularity, so this stores each file once and references it many times,
as Iceberg's own
+ * DeleteFileIndex does.
+ *
* @param scanExec
* The BatchScanExec whose inputRDD contains the DPP-filtered partitions
* @param output
@@ -727,9 +831,19 @@ object CometIcebergNativeScan extends
CometOperatorSerde[CometBatchScanExec] wit
val nameMappingToPoolIndex = mutable.HashMap[String, Int]()
val projectFieldIdsToPoolIndex = mutable.HashMap[Seq[Int], Int]()
val partitionDataToPoolIndex = mutable.HashMap[String, Int]()
+ // Individual delete files are interned into a flat pool keyed by path (a
delete file's path is
+ // its identity); deleteFilesToPoolIndex then dedups the per-task sets as
lists of indices into
+ // that pool. One delete file applies to many data files under Iceberg's
default partition
+ // delete granularity, so interning avoids re-serializing it once per
referencing FileScanTask.
+ val deleteFileToPoolIndex = mutable.HashMap[String, Int]()
val deleteFilesToPoolIndex =
- mutable.HashMap[Seq[OperatorOuterClass.IcebergDeleteFile], Int]()
- val residualToPoolIndex = mutable.HashMap[Option[Expr], Int]()
+ mutable.HashMap[Seq[Int], Int]()
+ val residualToPoolIndex =
mutable.HashMap[OperatorOuterClass.IcebergPredicate, Int]()
+ // Columns whose Iceberg type iceberg-rust cannot use for page-index
pruning; residual
+ // predicates over them are dropped (see icebergExprToProto). Computed
once from the full table
+ // schema so a filter column projected out of the scan output is still
recognized.
+ val pageIndexUnsupportedColumns =
+ IcebergReflection.pageIndexUnsupportedColumns(metadata.tableSchema)
val perPartitionBuilders =
mutable.ArrayBuffer[OperatorOuterClass.IcebergScan]()
@@ -907,11 +1021,21 @@ object CometIcebergNativeScan extends
CometOperatorSerde[CometBatchScanExec] wit
val deleteFilesList =
extractDeleteFilesList(task, contentFileClass,
fileScanTaskClass)
if (deleteFilesList.nonEmpty) {
+ // Intern each delete file into the flat pool, then dedup
this task's set as the
+ // resulting list of pool indices.
+ val deleteFileIndices = deleteFilesList.map { df =>
+ deleteFileToPoolIndex.getOrElseUpdate(
+ df.getFilePath, {
+ val idx = deleteFileToPoolIndex.size
+ commonBuilder.addDeleteFilePool(df)
+ idx
+ })
+ }
val deleteFilesIdx = deleteFilesToPoolIndex.getOrElseUpdate(
- deleteFilesList, {
+ deleteFileIndices, {
val idx = deleteFilesToPoolIndex.size
val listBuilder =
OperatorOuterClass.DeleteFileList.newBuilder()
- deleteFilesList.foreach(df =>
listBuilder.addDeleteFiles(df))
+ deleteFileIndices.foreach(idx =>
listBuilder.addDeleteFileIndices(idx))
commonBuilder.addDeleteFilesPool(listBuilder.build())
idx
})
@@ -920,11 +1044,10 @@ object CometIcebergNativeScan extends
CometOperatorSerde[CometBatchScanExec] wit
val residualExprOpt =
try {
- val residualExpr = residualMethod.invoke(task)
- val catalystExpr = convertIcebergExpression(residualExpr,
output)
- catalystExpr.flatMap { expr =>
- exprToProto(expr, output, binding = false)
- }
+ icebergExprToProto(
+ residualMethod.invoke(task),
+ output,
+ pageIndexUnsupportedColumns)
} catch {
case e: Exception =>
logWarning(
@@ -933,11 +1056,11 @@ object CometIcebergNativeScan extends
CometOperatorSerde[CometBatchScanExec] wit
None
}
- residualExprOpt.foreach { residualExpr =>
+ residualExprOpt.foreach { residual =>
val residualIdx = residualToPoolIndex.getOrElseUpdate(
- Some(residualExpr), {
+ residual, {
val idx = residualToPoolIndex.size
- commonBuilder.addResidualPool(residualExpr)
+ commonBuilder.addResidualPool(residual)
idx
})
taskBuilder.setResidualIdx(residualIdx)
@@ -996,6 +1119,7 @@ object CometIcebergNativeScan extends
CometOperatorSerde[CometBatchScanExec] wit
nameMappingToPoolIndex.size,
projectFieldIdsToPoolIndex.size,
partitionDataToPoolIndex.size,
+ deleteFileToPoolIndex.size,
deleteFilesToPoolIndex.size,
residualToPoolIndex.size)
@@ -1011,16 +1135,67 @@ object CometIcebergNativeScan extends
CometOperatorSerde[CometBatchScanExec] wit
}
}
- val partitionDataPoolBytes = commonBuilder.getPartitionDataPoolList.asScala
- .map(_.getSerializedSize)
- .sum
+ // Per-pool byte sizes to diagnose an oversized common message. Sizes sum
as Long because a
+ // single pool at or past protobuf's 2 GiB message limit overflows the int
getSerializedSize,
+ // and the logging runs before toByteArray so the breakdown survives even
if that allocation
+ // fails. String pools carry JSON, whose serialized size is its UTF-8
length.
+ def sumSizes(sizes: Iterator[Int]): Long = sizes.map(_.toLong).sum
+ def sumStrBytes(strings: mutable.Buffer[String]): Long =
+ strings.iterator.map(_.getBytes(UTF_8).length.toLong).sum
+ val commonPoolBytes: Seq[(String, Int, Long)] = Seq(
+ (
+ "schema",
+ commonBuilder.getSchemaPoolCount,
+ sumStrBytes(commonBuilder.getSchemaPoolList.asScala)),
+ (
+ "partition_type",
+ commonBuilder.getPartitionTypePoolCount,
+ sumStrBytes(commonBuilder.getPartitionTypePoolList.asScala)),
+ (
+ "partition_spec",
+ commonBuilder.getPartitionSpecPoolCount,
+ sumStrBytes(commonBuilder.getPartitionSpecPoolList.asScala)),
+ (
+ "name_mapping",
+ commonBuilder.getNameMappingPoolCount,
+ sumStrBytes(commonBuilder.getNameMappingPoolList.asScala)),
+ (
+ "project_field_ids",
+ commonBuilder.getProjectFieldIdsPoolCount,
+ sumSizes(
+
commonBuilder.getProjectFieldIdsPoolList.asScala.iterator.map(_.getSerializedSize))),
+ (
+ "partition_data",
+ commonBuilder.getPartitionDataPoolCount,
+ sumSizes(
+
commonBuilder.getPartitionDataPoolList.asScala.iterator.map(_.getSerializedSize))),
+ (
+ "delete_file",
+ commonBuilder.getDeleteFilePoolCount,
+
sumSizes(commonBuilder.getDeleteFilePoolList.asScala.iterator.map(_.getSerializedSize))),
+ (
+ "delete_files_set",
+ commonBuilder.getDeleteFilesPoolCount,
+
sumSizes(commonBuilder.getDeleteFilesPoolList.asScala.iterator.map(_.getSerializedSize))),
+ (
+ "residual",
+ commonBuilder.getResidualPoolCount,
+
sumSizes(commonBuilder.getResidualPoolList.asScala.iterator.map(_.getSerializedSize))))
+
+ val perPartitionSizes =
perPartitionBuilders.map(_.getSerializedSize.toLong)
+ val perPartitionTotal = perPartitionSizes.sum
+ val perPartitionMax = if (perPartitionSizes.isEmpty) 0L else
perPartitionSizes.max
logInfo(s"IcebergScan: $totalTasks tasks, ${allPoolSizes.size} pools
($avgDedup% avg dedup)")
- if (partitionDataToPoolIndex.nonEmpty) {
- logInfo(
- s" Partition data pool: ${partitionDataToPoolIndex.size} unique
values, " +
- s"$partitionDataPoolBytes bytes (protobuf)")
- }
+ logInfo(
+ " Common pools (unique/bytes): " +
+ commonPoolBytes
+ .map { case (name, count, bytes) => s"$name=$count/$bytes" }
+ .mkString(", ") +
+ s"; total=${commonPoolBytes.map(_._3).sum} bytes")
+ logInfo(
+ s" Per-partition messages: ${perPartitionBuilders.size}, " +
+ s"total=$perPartitionTotal bytes, max=$perPartitionMax bytes")
val commonBytes = commonBuilder.build().toByteArray
val perPartitionBytes = perPartitionBuilders.map(_.toByteArray).toArray
diff --git a/spark/src/test/scala/org/apache/comet/CometFuzzIcebergBase.scala
b/spark/src/test/scala/org/apache/comet/CometFuzzIcebergBase.scala
index fbace69140..88d3ff9b08 100644
--- a/spark/src/test/scala/org/apache/comet/CometFuzzIcebergBase.scala
+++ b/spark/src/test/scala/org/apache/comet/CometFuzzIcebergBase.scala
@@ -58,7 +58,7 @@ class CometFuzzIcebergBase extends CometTestBase with
AdaptiveSparkPlanHelper {
}
}
- private def isIcebergVersionLessThan(targetVersion: String): Boolean = {
+ protected def isIcebergVersionLessThan(targetVersion: String): Boolean = {
try {
val icebergVersion = org.apache.iceberg.IcebergBuild.version()
// Parse version strings like "1.5.2" or "1.6.0-SNAPSHOT"
diff --git a/spark/src/test/scala/org/apache/comet/CometFuzzIcebergSuite.scala
b/spark/src/test/scala/org/apache/comet/CometFuzzIcebergSuite.scala
index c6ac428938..e9bda4c6b4 100644
--- a/spark/src/test/scala/org/apache/comet/CometFuzzIcebergSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/CometFuzzIcebergSuite.scala
@@ -21,13 +21,13 @@ package org.apache.comet
import scala.util.Random
+import org.apache.spark.sql.Column
import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanExec
-import org.apache.spark.sql.internal.SQLConf
-import org.apache.spark.sql.internal.SQLConf.ParquetOutputTimestampType
+import org.apache.spark.sql.functions.{col, lit}
import org.apache.spark.sql.types._
import org.apache.comet.DataTypeSupport.isComplexType
-import org.apache.comet.testing.{DataGenOptions, FuzzDataGenerator,
ParquetGenerator, SchemaGenOptions}
+import org.apache.comet.serde.OperatorOuterClass
class CometFuzzIcebergSuite extends CometFuzzIcebergBase {
@@ -118,108 +118,120 @@ class CometFuzzIcebergSuite extends
CometFuzzIcebergBase {
}
}
- test("decode") {
+ test("filter pushdown - fuzz predicates over primitive columns") {
val df = spark.table(icebergTableName)
- // We want to make sure that the schema generator wasn't modified to
accidentally omit
- // BinaryType, since then this test would not run any queries and silently
pass.
- var testedBinary = false
- for (field <- df.schema.fields if field.dataType == BinaryType) {
- testedBinary = true
- // Intentionally use odd capitalization of 'utf-8' to test normalization.
- val sql = s"SELECT decode(${field.name}, 'utF-8') FROM $icebergTableName"
- checkSparkAnswerAndOperator(sql)
- }
- assert(testedBinary)
- }
+ val primitiveColumns =
+ df.schema.fields.filterNot(f => isComplexType(f.dataType)).map(_.name)
+ assert(primitiveColumns.nonEmpty, "expected at least one primitive column
in the fuzz schema")
+
+ for (name <- primitiveColumns) {
+ // Sample real values of the column's own type so predicates are always
well-typed, avoiding
+ // per-type literal generation and SQL formatting. Skip NaN, whose
comparison differs.
+ val values = df
+ .select(col(name))
+ .where(col(name).isNotNull)
+ .distinct()
+ .limit(3)
+ .collect()
+ .map(_.get(0))
+ .filterNot {
+ case d: Double => d.isNaN
+ case f: Float => f.isNaN
+ case _ => false
+ }
+ .toSeq
+
+ val c = col(name)
+ val predicates = scala.collection.mutable.ArrayBuffer[Column](c.isNull,
c.isNotNull)
+ values.headOption.foreach { v =>
+ val l = lit(v)
+ // Comparisons, not-equal and explicit NOT (exercises the native
rewrite_not path), and
+ // AND/OR combinations.
+ predicates ++= Seq(
+ c === l,
+ c =!= l,
+ c > l,
+ c >= l,
+ c < l,
+ c <= l,
+ !(c === l),
+ c.isNotNull && (c === l),
+ (c === l) || c.isNull)
+ }
+ if (values.nonEmpty) {
+ predicates += c.isin(values: _*)
+ predicates += !c.isin(values: _*)
+ }
- test("regexp_replace") {
- withSQLConf(CometConf.getExprAllowIncompatConfigKey("RegExpReplace") ->
"true") {
- val df = spark.table(icebergTableName)
- // We want to make sure that the schema generator wasn't modified to
accidentally omit
- // StringType, since then this test would not run any queries and
silently pass.
- var testedString = false
- for (field <- df.schema.fields if field.dataType == StringType) {
- testedString = true
- val sql = s"SELECT regexp_replace(${field.name}, 'a', 'b') FROM
$icebergTableName"
- checkSparkAnswerAndOperator(sql)
+ for (pred <- predicates) {
+ val (_, cometPlan) = checkSparkAnswer(df.where(pred))
+ assert(
+ collectIcebergNativeScans(cometPlan).nonEmpty,
+ s"expected a native Iceberg scan for predicate on '$name': $pred")
}
- assert(testedString)
}
}
- test("Iceberg temporal types written as INT96") {
- testIcebergTemporalTypes(ParquetOutputTimestampType.INT96)
- }
-
- test("Iceberg temporal types written as TIMESTAMP_MICROS") {
- testIcebergTemporalTypes(ParquetOutputTimestampType.TIMESTAMP_MICROS)
- }
-
- test("Iceberg temporal types written as TIMESTAMP_MILLIS") {
- testIcebergTemporalTypes(ParquetOutputTimestampType.TIMESTAMP_MILLIS)
- }
+ // Asserts that predicate pushdown actually happens for the types that
support it. For each
+ // primitive column it runs an equality filter and inspects the serialized
IcebergScanCommon: the
+ // residual pool must be non-empty exactly when the column's type is one
Comet maps to an Iceberg
+ // literal (pushable), and empty for a gated type (binary). This works
because the fuzz table is
+ // unpartitioned, so Iceberg's per-file residual is the full filter rather
than a partition-pruned
+ // remnant. It exists because the correctness test above cannot see pushdown
at all -- results
+ // match and the scan stays native whether or not a predicate pushes, since
CometFilter enforces
+ // it either way -- so only a residual-pool check catches a regression like
byte/short mapping to
+ // None or binary escaping the page-index gate.
+ test("filter pushdown - residual pool reflects whether the column type is
pushable") {
+ // Reading commonData forces Iceberg planning (ParallelIterable), which on
older Iceberg leaves
+ // a prefetched manifest stream open that Spark's DebugFilesystem flags at
teardown.
+ assume(
+ !isIcebergVersionLessThan("1.8.0"),
+ "ParallelIterable leaks manifest streams on older Iceberg")
+
+ // Types predicateLiteralToProto maps to an IcebergLiteral. Everything
else present in the fuzz
+ // table (binary) is gated or unmapped and must not push.
+ def isPushable(dt: DataType): Boolean = dt match {
+ case _: BooleanType | _: ByteType | _: ShortType | _: IntegerType | _:
LongType |
+ _: FloatType | _: DoubleType | _: DateType | _: StringType | _:
TimestampType |
+ _: TimestampNTZType =>
+ true
+ case _ => false
+ }
- private def testIcebergTemporalTypes(
- outputTimestampType: ParquetOutputTimestampType.Value,
- generateArray: Boolean = true,
- generateStruct: Boolean = true): Unit = {
-
- val schema = FuzzDataGenerator.generateSchema(
- SchemaGenOptions(
- generateArray = generateArray,
- generateStruct = generateStruct,
- primitiveTypes = SchemaGenOptions.defaultPrimitiveTypes.filterNot {
dataType =>
- // Disable decimals - iceberg-rust doesn't support
FIXED_LEN_BYTE_ARRAY in page index yet
- dataType.isInstanceOf[DecimalType]
- }))
-
- val options =
- DataGenOptions(generateNegativeZero = false)
-
- withTempPath { filename =>
- val random = new Random(42)
- withSQLConf(
- CometConf.COMET_ENABLED.key -> "false",
- SQLConf.PARQUET_OUTPUT_TIMESTAMP_TYPE.key ->
outputTimestampType.toString,
- SQLConf.SESSION_LOCAL_TIMEZONE.key -> defaultTimezone) {
- ParquetGenerator.makeParquetFile(random, spark, filename.toString,
schema, 100, options)
- }
+ val df = spark.table(icebergTableName)
+ val primitiveColumns = df.schema.fields.filterNot(f =>
isComplexType(f.dataType))
+ assert(primitiveColumns.nonEmpty, "expected at least one primitive column
in the fuzz schema")
+
+ for (field <- primitiveColumns) {
+ val name = field.name
+ val sample = df
+ .select(col(name))
+ .where(col(name).isNotNull)
+ .limit(1)
+ .collect()
+ .headOption
+ .map(_.get(0))
+ // Skip NaN, whose equality semantics differ, so the sampled literal
is well-behaved.
+ .filterNot {
+ case d: Double => d.isNaN
+ case f: Float => f.isNaN
+ case _ => false
+ }
- Seq(defaultTimezone, "UTC", "America/Denver").foreach { tz =>
- Seq(true, false).foreach { inferTimestampNtzEnabled =>
- Seq(true, false).foreach { int96TimestampConversion =>
- Seq(true, false).foreach { int96AsTimestamp =>
- withSQLConf(
- CometConf.COMET_ENABLED.key -> "true",
- SQLConf.SESSION_LOCAL_TIMEZONE.key -> tz,
- SQLConf.PARQUET_INT96_AS_TIMESTAMP.key ->
int96AsTimestamp.toString,
- SQLConf.PARQUET_INT96_TIMESTAMP_CONVERSION.key ->
int96TimestampConversion.toString,
- SQLConf.PARQUET_INFER_TIMESTAMP_NTZ_ENABLED.key ->
inferTimestampNtzEnabled.toString) {
-
- val df = spark.table(icebergTableName)
-
- Seq(defaultTimezone, "UTC", "America/Denver").foreach { tz =>
- withSQLConf(SQLConf.SESSION_LOCAL_TIMEZONE.key -> tz) {
- def hasTemporalType(t: DataType): Boolean = t match {
- case DataTypes.DateType | DataTypes.TimestampType |
- DataTypes.TimestampNTZType =>
- true
- case t: StructType => t.exists(f =>
hasTemporalType(f.dataType))
- case t: ArrayType => hasTemporalType(t.elementType)
- case _ => false
- }
-
- val columns =
- df.schema.fields.filter(f =>
hasTemporalType(f.dataType)).map(_.name)
-
- for (col <- columns) {
- checkSparkAnswer(s"SELECT $col FROM $icebergTableName
ORDER BY $col")
- }
- }
- }
- }
- }
- }
+ sample.foreach { v =>
+ val (_, cometPlan) = checkSparkAnswer(df.where(col(name) === lit(v)))
+ val scans = collectIcebergNativeScans(cometPlan)
+ assert(scans.length == 1, s"expected one native scan for
'$name'\n$cometPlan")
+ val common =
OperatorOuterClass.IcebergScanCommon.parseFrom(scans.head.commonData)
+ val pushed = common.getResidualPoolCount > 0
+ if (isPushable(field.dataType)) {
+ assert(
+ pushed,
+ s"expected a pushed residual for pushable column '$name'
(${field.dataType})")
+ } else {
+ assert(
+ !pushed,
+ s"expected no residual pushdown for gated column '$name'
(${field.dataType})")
}
}
}
diff --git
a/spark/src/test/scala/org/apache/comet/CometIcebergNativeSuite.scala
b/spark/src/test/scala/org/apache/comet/CometIcebergNativeSuite.scala
index 7b9bd6b3d6..bdad3163d4 100644
--- a/spark/src/test/scala/org/apache/comet/CometIcebergNativeSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/CometIcebergNativeSuite.scala
@@ -21,6 +21,7 @@ package org.apache.comet
import java.io.File
import java.net.URI
+import java.nio.charset.StandardCharsets.UTF_8
import scala.collection.mutable
import scala.jdk.CollectionConverters._
@@ -39,6 +40,7 @@ import org.apache.spark.sql.types.{StringType, TimestampType}
import org.apache.comet.CometSparkSessionExtensions.{isSpark35Plus,
isSpark40Plus, isSpark42Plus}
import org.apache.comet.iceberg.RESTCatalogHelper
+import org.apache.comet.serde.OperatorOuterClass
import org.apache.comet.testing.{FuzzDataGenerator, SchemaGenOptions}
/**
@@ -96,6 +98,24 @@ class CometIcebergNativeSuite
s"Plan:\n$cometPlan")
}
+ /** Counts non-overlapping occurrences of `needle` within `haystack`. */
+ private def countByteOccurrences(haystack: Array[Byte], needle:
Array[Byte]): Int = {
+ require(needle.nonEmpty, "needle must be non-empty")
+ var count = 0
+ var i = 0
+ while (i <= haystack.length - needle.length) {
+ var j = 0
+ while (j < needle.length && haystack(i + j) == needle(j)) j += 1
+ if (j == needle.length) {
+ count += 1
+ i += needle.length
+ } else {
+ i += 1
+ }
+ }
+ count
+ }
+
test("create and query simple Iceberg table with Hadoop catalog") {
assume(icebergAvailable, "Iceberg not available in classpath")
@@ -575,6 +595,94 @@ class CometIcebergNativeSuite
}
}
+ // Under Iceberg's default partition delete granularity, one position-delete
file applies to every
+ // data file in the partition with a compatible sequence number
(DeleteFileIndex.forDataFile).
+ // Interleaving inserts and deletes staggers data-file sequence numbers, so
each FileScanTask sees
+ // a different subset of the same delete files. A shared delete file must be
serialized once in
+ // the broadcast IcebergScanCommon, not once per referencing set:
duplicating it is quadratic in
+ // the number of delete commits and can overflow protobuf's 2 GiB message
limit
+ // (getSerializedSize() returns int, so it wraps to a negative array length).
+ test("delete file pool does not duplicate shared delete files (serde size)")
{
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ // Reading scan.commonData forces Iceberg planning (ParallelIterable),
which on older Iceberg
+ // leaves a prefetched manifest stream open that Spark's DebugFilesystem
flags at teardown.
+ assume(
+ icebergVersionAtLeast(1, 8),
+ "ParallelIterable leaks manifest streams on older Iceberg")
+
+ 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") {
+
+ spark.sql("""
+ CREATE TABLE test_cat.db.delete_pool_test (id INT, name STRING)
+ USING iceberg
+ TBLPROPERTIES (
+ 'write.delete.mode' = 'merge-on-read',
+ 'write.merge.mode' = 'merge-on-read',
+ 'write.delete.granularity' = 'partition'
+ )
+ """)
+
+ // Interleave inserts and deletes. Each INSERT lands a data file at an
increasing sequence
+ // number; each DELETE lands one partition-granularity position-delete
file that references
+ // (and applies to) every prior data file still holding a matching
row. Data file written in
+ // round r therefore sees delete files from rounds r..N-1, a distinct
set per data file.
+ val rounds = 5
+ for (r <- 0 until rounds) {
+ val base = r * 100
+ val values = (1 to 50).map(i => s"(${base + i}, 'n${base +
i}')").mkString(", ")
+ spark.sql(s"INSERT INTO test_cat.db.delete_pool_test VALUES $values")
+ // Delete one still-present row from every batch inserted so far so
this delete file
+ // references multiple data files (partition granularity) and
applies to all of them. The
+ // per-round offset (r + 2) is unique, so no row is deleted twice
(which would make the
+ // delete match only the newest batch and defeat the staggering).
+ val ids = (0 to r).map(b => b * 100 + (r + 2)).mkString(", ")
+ spark.sql(s"DELETE FROM test_cat.db.delete_pool_test WHERE id IN
($ids)")
+ }
+
+ val (_, cometPlan) =
+ checkSparkAnswer("SELECT * FROM test_cat.db.delete_pool_test ORDER
BY id")
+ val scans = collectIcebergNativeScans(cometPlan)
+ assert(scans.length == 1, s"expected one native scan, got
${scans.length}\n$cometPlan")
+
+ val commonBytes = scans.head.commonData
+ val common =
OperatorOuterClass.IcebergScanCommon.parseFrom(commonBytes)
+
+ val distinctPaths =
+ common.getDeleteFilePoolList.asScala.map(_.getFilePath).toSeq
+ val totalReferences =
+
common.getDeleteFilesPoolList.asScala.map(_.getDeleteFileIndicesCount).sum
+
+ // Guard against a vacuous pass: we must actually exercise the
shared-delete-file case
+ // (more references than distinct files, i.e. at least one file shared
across tasks).
+ assert(
+ distinctPaths.size >= 2 && totalReferences > distinctPaths.size,
+ s"test setup produced too few shared delete files:
${distinctPaths.size} files, " +
+ s"$totalReferences references")
+
+ // Count raw-byte occurrences of each path so the check is agnostic to
how the pool is
+ // structured (set-of-copies before the fix, flat index pool after).
+ val duplicated = distinctPaths
+ .map(p => p -> countByteOccurrences(commonBytes, p.getBytes(UTF_8)))
+ .filter(_._2 > 1)
+
+ assert(
+ duplicated.isEmpty,
+ "delete files serialized more than once in IcebergScanCommon " +
+ s"(${commonBytes.length} bytes): " +
+ duplicated.map { case (p, c) => s"$p x$c" }.mkString(", "))
+
+ spark.sql("DROP TABLE test_cat.db.delete_pool_test")
+ }
+ }
+ }
+
test("bytes_scanned includes delete file I/O") {
assume(icebergAvailable, "Iceberg not available in classpath")
@@ -2542,6 +2650,324 @@ class CometIcebergNativeSuite
}
}
+ // Reproducer for the iceberg-rust FIXED_LEN_BYTE_ARRAY page-index gap
(comet#4982), mirroring
+ // Iceberg's TestSparkReaderWithBloomFilter: a table with decimal columns
(stored as
+ // FIXED_LEN_BYTE_ARRAY), bloom filters, tiny row groups (so Parquet writes
a page index), and a
+ // wide AND filter that includes decimal equalities. Getting this to fail
lets us instrument why a
+ // predicate reaches iceberg-rust's PageIndexEvaluator on a decimal column.
+ test("filter on a table with a decimal column does not fail the native
scan") {
+ 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") {
+
+ spark.sql("""
+ CREATE TABLE test_cat.db.decimal_filter_test (
+ id INT,
+ id_long BIGINT,
+ id_double DOUBLE,
+ id_float FLOAT,
+ id_string STRING,
+ id_boolean BOOLEAN,
+ id_date DATE,
+ id_int_decimal DECIMAL(8, 2),
+ id_long_decimal DECIMAL(14, 2),
+ id_fixed_decimal DECIMAL(31, 2)
+ ) USING iceberg
+ TBLPROPERTIES (
+ 'format-version' = '2',
+ 'write.parquet.row-group-size-bytes' = '100',
+ 'write.parquet.bloom-filter-enabled.column.id' = 'true',
+ 'write.parquet.bloom-filter-enabled.column.id_long' = 'true',
+ 'write.parquet.bloom-filter-enabled.column.id_double' = 'true',
+ 'write.parquet.bloom-filter-enabled.column.id_float' = 'true',
+ 'write.parquet.bloom-filter-enabled.column.id_string' = 'true',
+ 'write.parquet.bloom-filter-enabled.column.id_boolean' = 'true',
+ 'write.parquet.bloom-filter-enabled.column.id_date' = 'true',
+ 'write.parquet.bloom-filter-enabled.column.id_int_decimal' =
'true',
+ 'write.parquet.bloom-filter-enabled.column.id_long_decimal' =
'true',
+ 'write.parquet.bloom-filter-enabled.column.id_fixed_decimal' =
'true'
+ )
+ """)
+
+ // 300 rows (ids 30..329), matching the Iceberg test's value formulas.
+ spark.sql("""
+ INSERT INTO test_cat.db.decimal_filter_test
+ SELECT
+ CAST(id AS INT),
+ CAST(id + 1000 AS BIGINT),
+ CAST(id + 10000 AS DOUBLE),
+ CAST(id + 100000 AS FLOAT),
+ CONCAT('BINARY_', CAST(id AS STRING)),
+ true,
+ DATE '2021-09-05',
+ CAST(77.77 AS DECIMAL(8, 2)),
+ CAST(88.88 AS DECIMAL(14, 2)),
+ CAST(99.99 AS DECIMAL(31, 2))
+ FROM range(30, 330)
+ """)
+
+ checkIcebergNativeScan(
+ "SELECT * FROM test_cat.db.decimal_filter_test WHERE id = 30 AND
id_long = 1030 " +
+ "AND id_double = 10030.0 AND id_float = 100030.0 AND id_string =
'BINARY_30' " +
+ "AND id_boolean = true AND id_date = '2021-09-05' AND
id_int_decimal = 77.77 " +
+ "AND id_long_decimal = 88.88 AND id_fixed_decimal = 99.99")
+
+ spark.sql("DROP TABLE test_cat.db.decimal_filter_test")
+ }
+ }
+ }
+
+ // Iceberg stores uuid as Parquet FIXED_LEN_BYTE_ARRAY(16), the same
physical layout as decimal
+ // and fixed. iceberg-rust's page-index evaluator does not support
FIXED_LEN_BYTE_ARRAY, so a
+ // predicate pushed over such a column fails the native scan during
page-index pruning. The scan
+ // must drop the residual and stay native, leaving the filter to the
post-scan CometFilter.
+ // The trigger is IS NOT NULL (a unary predicate): it binds without a
literal and reaches the
+ // page-index read. An equality would push a string literal that
iceberg-rust cannot bind to a
+ // UUID field (Datum bind does not coerce), dropping the whole residual
before any page probe.
+ test("filter on a table with a uuid column does not fail the native scan") {
+ 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") {
+
+ import org.apache.iceberg.catalog.TableIdentifier
+ import org.apache.iceberg.spark.SparkCatalog
+ import org.apache.iceberg.types.Types
+ import org.apache.iceberg.{PartitionSpec, Schema}
+
+ val sparkCatalog = spark.sessionState.catalogManager
+ .catalog("test_cat")
+ .asInstanceOf[SparkCatalog]
+
+ spark.sql("CREATE NAMESPACE IF NOT EXISTS test_cat.db")
+
+ // uuid is not expressible via Spark SQL CREATE TABLE, so build the
table with the Iceberg
+ // API. A tiny row-group size forces multiple row groups, so Parquet
writes a column index
+ // for the uuid column that the pushed predicate then probes.
+ val schema = new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "u", Types.UUIDType.get()))
+ val tableIdent = TableIdentifier.of("db", "uuid_filter_test")
+ val props = new java.util.HashMap[String, String]()
+ props.put("format-version", "2")
+ props.put("write.parquet.row-group-size-bytes", "100")
+ sparkCatalog.icebergCatalog
+ .createTable(tableIdent, schema, PartitionSpec.unpartitioned(),
props)
+
+ spark.sql("""
+ INSERT INTO test_cat.db.uuid_filter_test
+ SELECT CAST(id AS INT), 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'
+ FROM range(30, 330)
+ """)
+
+ checkIcebergNativeScan(
+ "SELECT * FROM test_cat.db.uuid_filter_test WHERE id = 30 AND u IS
NOT NULL")
+
+ spark.sql("DROP TABLE test_cat.db.uuid_filter_test")
+ }
+ }
+ }
+
+ // Iceberg stores fixed[N] as Parquet FIXED_LEN_BYTE_ARRAY(N). As with uuid
and decimal,
+ // iceberg-rust's page-index evaluator does not support that physical type,
so the IS NOT NULL
+ // pushed over the fixed column (added by Iceberg for any filtered column)
would fail the native
+ // scan. The residual must be dropped so the scan stays native and
CometFilter enforces it.
+ test("filter on a table with a fixed column does not fail the native scan") {
+ 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") {
+
+ import org.apache.iceberg.catalog.TableIdentifier
+ import org.apache.iceberg.spark.SparkCatalog
+ import org.apache.iceberg.types.Types
+ import org.apache.iceberg.{PartitionSpec, Schema}
+
+ val sparkCatalog = spark.sessionState.catalogManager
+ .catalog("test_cat")
+ .asInstanceOf[SparkCatalog]
+
+ spark.sql("CREATE NAMESPACE IF NOT EXISTS test_cat.db")
+
+ val schema = new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "f", Types.FixedType.ofLength(16)))
+ val tableIdent = TableIdentifier.of("db", "fixed_filter_test")
+ val props = new java.util.HashMap[String, String]()
+ props.put("format-version", "2")
+ props.put("write.parquet.row-group-size-bytes", "100")
+ sparkCatalog.icebergCatalog
+ .createTable(tableIdent, schema, PartitionSpec.unpartitioned(),
props)
+
+ spark.sql("""
+ INSERT INTO test_cat.db.fixed_filter_test
+ SELECT CAST(id AS INT), X'00112233445566778899aabbccddeeff'
+ FROM range(30, 330)
+ """)
+
+ checkIcebergNativeScan(
+ "SELECT * FROM test_cat.db.fixed_filter_test WHERE id = 30 AND f IS
NOT NULL")
+
+ spark.sql("DROP TABLE test_cat.db.fixed_filter_test")
+ }
+ }
+ }
+
+ // Iceberg stores binary as Parquet BYTE_ARRAY. Unlike the
FIXED_LEN_BYTE_ARRAY types above,
+ // iceberg-rust's page-index evaluator accepts BYTE_ARRAY but decodes its
column-index min/max as
+ // UTF-8 (String::from_utf8(..).unwrap()), so non-UTF-8 bounds panic the
native scan. The decode
+ // happens in calc_row_selection before the null-count closure runs, so even
a bare IS NOT NULL
+ // (added by Iceberg for any filtered column) triggers it. Binary must
therefore be gated like the
+ // FIXED_LEN_BYTE_ARRAY types: the residual is dropped and CometFilter
enforces the filter.
+ test("filter on a table with a binary column does not fail the native scan")
{
+ 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") {
+
+ spark.sql("""
+ CREATE TABLE test_cat.db.binary_filter_test (
+ id INT,
+ b BINARY
+ ) USING iceberg
+ TBLPROPERTIES (
+ 'format-version' = '2',
+ 'write.parquet.row-group-size-bytes' = '100'
+ )
+ """)
+
+ // Prepend a 0xFF byte (never valid UTF-8) so the column-index min/max
for `b` cannot decode
+ // as a UTF-8 string, which is what makes iceberg-rust's page-index
evaluator panic.
+ spark.sql("""
+ INSERT INTO test_cat.db.binary_filter_test
+ SELECT CAST(id AS INT), concat(X'FF', CAST(CAST(id AS STRING) AS
BINARY))
+ FROM range(30, 330)
+ """)
+
+ checkIcebergNativeScan(
+ "SELECT * FROM test_cat.db.binary_filter_test WHERE id = 30 AND b IS
NOT NULL")
+
+ spark.sql("DROP TABLE test_cat.db.binary_filter_test")
+ }
+ }
+ }
+
+ // A residual can arrive as NOT over an AND that mixes a supported conjunct
with an unsupported
+ // (FIXED_LEN_BYTE_ARRAY) one. Dropping only the unsupported conjunct is
safe in positive
+ // position (it weakens the pruning predicate), but under a NOT it
strengthens it: NOT(id < 200
+ // AND d = 100.00) is really id >= 200 OR d != 100.00, yet dropping the
decimal conjunct and
+ // negating leaves id >= 200, which prunes pages holding id < 200 rows that
satisfy the filter
+ // via d != 100.00. The residual must not be pushed partially in that case.
This asserts results
+ // match Spark; d is stored as decimal so its predicate is dropped by the
page-index gate.
+ test("NOT over a partially supported AND does not drop rows") {
+ 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") {
+
+ spark.sql("""
+ CREATE TABLE test_cat.db.not_partial_residual_test (
+ id INT,
+ d DECIMAL(10, 2)
+ ) USING iceberg
+ TBLPROPERTIES (
+ 'format-version' = '2',
+ 'write.parquet.row-group-size-bytes' = '100'
+ )
+ """)
+
+ // id ascending so row groups hold contiguous id ranges; a stronger id
>= 200 pushed
+ // predicate would prune the id < 200 groups entirely.
+ spark.sql("""
+ INSERT INTO test_cat.db.not_partial_residual_test
+ SELECT CAST(id AS INT), CAST(id AS DECIMAL(10, 2))
+ FROM range(30, 330)
+ """)
+
+ checkIcebergNativeScan(
+ "SELECT * FROM test_cat.db.not_partial_residual_test " +
+ "WHERE NOT(id < 200 AND d = 100.00)")
+
+ spark.sql("DROP TABLE test_cat.db.not_partial_residual_test")
+ }
+ }
+ }
+
+ // Companion to the partial-AND test above: here both conjuncts are pushable
(both int), so the
+ // whole residual is pushed as NOT(a < 200 AND b > 100). rewrite_not on the
native side turns that
+ // into a >= 200 OR b <= 100 (negation-normal form), exercising the
both-convert-under-NOT path
+ // that the partial-AND elision test cannot reach. Asserts results match
Spark.
+ test("NOT over a fully supported AND does not drop rows") {
+ 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") {
+
+ spark.sql("""
+ CREATE TABLE test_cat.db.not_full_residual_test (
+ a INT,
+ b INT
+ ) USING iceberg
+ TBLPROPERTIES (
+ 'format-version' = '2',
+ 'write.parquet.row-group-size-bytes' = '100'
+ )
+ """)
+
+ // a ascending so row groups hold contiguous a ranges; a stronger a >=
200 pushed
+ // predicate (from dropping the b conjunct under the NOT) would prune
the a < 200 groups.
+ spark.sql("""
+ INSERT INTO test_cat.db.not_full_residual_test
+ SELECT CAST(id AS INT), CAST(id AS INT)
+ FROM range(30, 330)
+ """)
+
+ checkIcebergNativeScan(
+ "SELECT * FROM test_cat.db.not_full_residual_test " +
+ "WHERE NOT(a < 200 AND b > 100)")
+
+ spark.sql("DROP TABLE test_cat.db.not_full_residual_test")
+ }
+ }
+ }
+
// Regression test for https://github.com/apache/datafusion-comet/issues/3856
// Fixed in https://github.com/apache/iceberg-rust/pull/2301
test("migration - INT96 timestamp") {
@@ -2980,6 +3406,76 @@ class CometIcebergNativeSuite
}
}
+ // A large static filter on a non-partition column gives every file the
identical residual, so
+ // the residual pool must collapse to a single entry. Residuals serialize as
IcebergPredicate
+ // without expr_id/query_context, so structurally-equal residuals dedup; a
regression that
+ // reintroduced per-node identity metadata would make the pool grow with the
task count and could
+ // overflow the broadcast IcebergScanCommon.
+ test("residual pool dedups a shared non-partition filter to one entry") {
+ assume(icebergAvailable, "Iceberg not available in classpath")
+ // Reading scan.commonData forces Iceberg planning (ParallelIterable),
which on older Iceberg
+ // leaves a prefetched manifest stream open that Spark's DebugFilesystem
flags at teardown.
+ assume(
+ icebergVersionAtLeast(1, 8),
+ "ParallelIterable leaks manifest streams on older Iceberg")
+
+ 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") {
+
+ spark.sql("""
+ CREATE TABLE test_cat.db.residual_dedup_test (id INT, region STRING,
payload STRING)
+ USING iceberg
+ PARTITIONED BY (region)
+ TBLPROPERTIES ('format-version' = '2')
+ """)
+
+ // One data file per region, so the scan yields many FileScanTasks.
The filter below is on
+ // payload, a non-partition column, so each task's residual is the
identical full IN.
+ val values = (0 until 40)
+ .flatMap(r => (0 until 25).map(i => s"($i, 'r$r', 'p$i')"))
+ .mkString(", ")
+ spark.sql(s"INSERT INTO test_cat.db.residual_dedup_test VALUES
$values")
+
+ val inList = (0 until 200).map(i => s"'p$i'").mkString(", ")
+ val (_, cometPlan) = checkSparkAnswer(
+ "SELECT * FROM test_cat.db.residual_dedup_test " +
+ s"WHERE payload IN ($inList) ORDER BY id, region")
+ val scans = collectIcebergNativeScans(cometPlan)
+ assert(scans.length == 1, s"expected one native scan, got
${scans.length}\n$cometPlan")
+
+ val common =
OperatorOuterClass.IcebergScanCommon.parseFrom(scans.head.commonData)
+ val residuals = common.getResidualPoolList.asScala.toSeq
+
+ // Count how many FileScanTasks reference a residual across all
partitions. Every task here
+ // carries the same IN predicate, so a correct pool holds one entry
shared by all of them.
+ val tasksWithResidual = scans.head.perPartitionData.map { bytes =>
+ OperatorOuterClass.IcebergScan
+ .parseFrom(bytes)
+ .getFileScanTasksList
+ .asScala
+ .count(_.hasResidualIdx)
+ }.sum
+
+ // Guard against a vacuous pass: the dedup is only meaningful if many
tasks share the entry.
+ assert(
+ tasksWithResidual > 1,
+ s"expected multiple tasks to carry a residual, got
$tasksWithResidual")
+ assert(
+ residuals.size == 1,
+ "expected the shared non-partition residual to dedup to 1 pool entry
across " +
+ s"$tasksWithResidual task references, got ${residuals.size}")
+
+ spark.sql("DROP TABLE test_cat.db.residual_dedup_test")
+ }
+ }
+ }
+
// Regression test for https://github.com/apache/datafusion-comet/issues/3860
// Fixed in https://github.com/apache/iceberg-rust/pull/2307
test("filter with nested types in migrated table") {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]