Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a68a595
refactor: use Arrow casts for temporal conversions
peterxcli Jul 31, 2026
263a61e
fix: preserve Spark temporal cast semantics
peterxcli Aug 2, 2026
bd1d243
test: match Spark micros-to-millis cases
peterxcli Aug 2, 2026
fbb2a7c
test: link Spark micros-to-millis cases
peterxcli Aug 2, 2026
47211ff
test: use Spark tag in source link
peterxcli Aug 2, 2026
6afef50
Use imported arity kernel for Spark timestamp downscaling
peterxcli Aug 2, 2026
e71f480
Merge branch 'main' into refactor/5090-use-arrow-temporal-casts
peterxcli Aug 2, 2026
88685aa
fix: enforce Spark temporal conversion semantics
peterxcli Aug 2, 2026
35920f6
andy's 3rd review
peterxcli Aug 8, 2026
6e256f3
Merge branch 'main' into refactor/5090-use-arrow-temporal-casts
peterxcli Aug 8, 2026
212f990
Merge branch 'main' into refactor/5090-use-arrow-temporal-casts
peterxcli Aug 21, 2026
7c33c92
Merge branch 'main' into refactor/5090-use-arrow-temporal-casts
peterxcli Aug 24, 2026
ae0152f
address review: drop temporal.rs refactor, improve adapter error message
peterxcli Aug 28, 2026
afa3673
chore: remove unused imports in parquet_support
peterxcli Aug 28, 2026
c833b72
test: exercise dictionary-encoded pages in TIMESTAMP_MILLIS overflow …
peterxcli Aug 28, 2026
5c8de42
Merge branch 'main' into refactor/5090-use-arrow-temporal-casts
peterxcli Aug 28, 2026
d465192
fix: preserve timestamp pruning by rewriting predicates to the millis…
peterxcli Aug 29, 2026
0d78875
Merge remote branch updates
peterxcli Aug 29, 2026
d2b6b14
Merge branch 'main' into refactor/5090-use-arrow-temporal-casts
peterxcli Aug 29, 2026
40b92b7
fix: cover IN, null-safe equality, and nested predicates in the milli…
peterxcli Aug 29, 2026
9172861
Merge remote branch updates
peterxcli Aug 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
321 changes: 114 additions & 207 deletions native/core/src/parquet/cast_column.rs

Large diffs are not rendered by default.

19 changes: 18 additions & 1 deletion native/core/src/parquet/parquet_exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,18 @@ use datafusion::physical_expr::PhysicalExpr;
use datafusion::physical_expr_adapter::PhysicalExprAdapterFactory;
use datafusion::prelude::SessionContext;
use datafusion::scalar::ScalarValue;
use datafusion_comet_spark_expr::EvalMode;
use datafusion_comet_spark_expr::{EvalMode, GetStructField};
use datafusion_datasource::TableSchema;
use std::collections::HashMap;
use std::sync::Arc;

/// True when the expression reads a struct field (directly or in any child), i.e. the
/// predicate references a nested column.
fn references_nested_field(expr: &Arc<dyn PhysicalExpr>) -> bool {
expr.downcast_ref::<GetStructField>().is_some()
|| expr.children().iter().any(|c| references_nested_field(c))
}

/// Initializes a DataSourceExec plan with a ParquetSource for Comet's native Parquet scan.
///
/// `required_schema`: Schema to be projected by the scan.
Expand Down Expand Up @@ -93,6 +100,16 @@ pub(crate) fn init_datasource_exec(
);
spark_parquet_options.use_field_id = use_field_id;
spark_parquet_options.ignore_missing_field_id = ignore_missing_field_id;
// Spark only avoids the TIMESTAMP_MILLIS overflow error for filtered-out values through
// row-group statistics pruning. DataFusion can neither prune nested-field predicates
// (`PruningPredicate` has no nested-field support) nor evaluate them as Parquet row
// filters (struct columns are classified non-pushable), so a scan carrying such a
// predicate would decode row groups Spark prunes and fail on the checked conversion of
// any overflowing top-level column. Fall back to the safe cast (overflow -> NULL) for
// these scans; the filter above the scan then discards the rows like Spark's pruning.
spark_parquet_options.checked_timestamp_overflow = !data_filters
.as_ref()
.is_some_and(|filters| filters.iter().any(references_nested_field));

// Determine the schema and projection to use for ParquetSource.
// When data_schema is provided, use it as the base schema so DataFusion knows the full
Expand Down
127 changes: 121 additions & 6 deletions native/core/src/parquet/parquet_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ use arrow::compute::can_cast_types;
use arrow::datatypes::{FieldRef, Fields};
use arrow::{
array::{
cast::AsArray, new_null_array, types::TimestampMicrosecondType, Array, ArrayRef,
StructArray,
cast::AsArray, new_null_array, types::TimestampMicrosecondType,
types::TimestampMillisecondType, Array, ArrayRef, ArrowNativeTypeOp, StructArray,
},
compute::{cast_with_options, CastOptions},
datatypes::{DataType, TimeUnit},
Expand Down Expand Up @@ -99,6 +99,16 @@ pub struct SparkParquetOptions {
/// (Spark 3.x, SPARK-36182). Mirrors Comet's per-Spark-version constant
/// in ShimCometConf.
pub allow_timestamp_ltz_to_ntz: bool,
/// When true (the default), a top-level TIMESTAMP_MILLIS column that overflows during
/// the millis->micros upscale raises an error, matching Spark's checked
/// `millisToMicros`. Scans whose data filters reference nested struct fields set this
/// to false and fall back to the safe cast (overflow -> NULL): Spark only avoids the
/// overflow error for filtered-out values through row-group statistics pruning, and
/// DataFusion can neither prune (`PruningPredicate` has no nested-field support) nor
/// row-filter (struct columns are classified non-pushable in
/// `can_expr_be_pushed_down_with_schemas`) such predicates, so a checked conversion
/// would fail queries Spark answers with zero rows.
pub checked_timestamp_overflow: bool,
}

impl SparkParquetOptions {
Expand All @@ -115,6 +125,7 @@ impl SparkParquetOptions {
ignore_missing_field_id: false,
allow_type_promotion: false,
allow_timestamp_ltz_to_ntz: false,
checked_timestamp_overflow: true,
}
}

Expand All @@ -131,6 +142,7 @@ impl SparkParquetOptions {
ignore_missing_field_id: false,
allow_type_promotion: false,
allow_timestamp_ltz_to_ntz: false,
checked_timestamp_overflow: true,
}
}
}
Expand Down Expand Up @@ -167,6 +179,15 @@ fn parquet_convert_array(
array: ArrayRef,
to_type: &DataType,
parquet_options: &SparkParquetOptions,
) -> DataFusionResult<ArrayRef> {
parquet_convert_array_impl(array, to_type, parquet_options, true)
}

fn parquet_convert_array_impl(
array: ArrayRef,
to_type: &DataType,
parquet_options: &SparkParquetOptions,
top_level: bool,
) -> DataFusionResult<ArrayRef> {
use DataType::*;
let from_type = array.data_type();
Expand All @@ -182,10 +203,11 @@ fn parquet_convert_array(
)?),
(List(_), List(to_inner_type)) => {
let list_arr: &ListArray = array.as_list();
let cast_field = parquet_convert_array(
let cast_field = parquet_convert_array_impl(
Arc::clone(list_arr.values()),
to_inner_type.data_type(),
parquet_options,
false,
)?;

Ok(Arc::new(ListArray::new(
Expand All @@ -195,6 +217,28 @@ fn parquet_convert_array(
list_arr.nulls().cloned(),
)))
}
(
Timestamp(TimeUnit::Millisecond, _),
Timestamp(TimeUnit::Microsecond, target_tz),
) if top_level && parquet_options.checked_timestamp_overflow => {
// Spark's Parquet reader calls the checked `millisToMicros` conversion for both
// direct and dictionary values, independent of CAST evaluation mode:
// https://github.com/apache/spark/blob/v4.2.0/sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/ParquetVectorUpdaterFactory.java#L817-L833
// `millisToMicros` uses `Math.multiplyExact`:
// https://github.com/apache/spark/blob/v4.2.0/sql/api/src/main/scala/org/apache/spark/sql/catalyst/util/SparkDateTimeUtils.scala#L103-L108
//
// The checked conversion is limited to TOP-LEVEL columns. Spark only avoids the
// error for filtered-out values through row-group statistics pruning, and
// DataFusion's PruningPredicate does not support nested fields yet, so a checked
// conversion on a nested field would fail queries whose predicates Spark prunes
// (e.g. `WHERE s.ts < X` over an all-overflowing file). Nested fields keep the
// pre-existing safe-cast behavior below (overflow -> NULL).
let micros = array
.as_primitive::<TimestampMillisecondType>()
.try_unary::<_, TimestampMicrosecondType, _>(|value| value.mul_checked(1_000))?
.with_timezone_opt(target_tz.clone());
Comment on lines +236 to +239

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve timestamp pruning before checked conversion

With a TimestampType column stored as TIMESTAMP_MILLIS and containing 9223372036854776 milliseconds, WHERE ts < TIMESTAMP '1970-01-01 00:00:00' returns no rows in Spark 4.1.3 and the base native build (c067e4e), but 5c8de42 throws Overflow happened on: 9223372036854776 * 1000. I reproduced this with plain/dictionary encoding, ANSI on/off, and Comet row-filter pushdown on/off: all eight Spark/base cases succeed and all eight head cases fail.

The existing CometCastColumnExpr prevents DataFusion from recognizing the timestamp statistics predicate, so Comet converts values from row groups Spark skips. This new error turns that pruning limitation into a query failure. Please preserve pruning before the checked conversion and add this filtered case as a regression test, while retaining overflow errors for values actually read.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed — thanks for the thorough repro. The predicate's column was wrapped in CometCastColumnExpr, which is opaque to DataFusion's pruning analyzer, so the row group Spark prunes from millisecond statistics was being read and converted. The fix rewrites predicate comparisons over a TIMESTAMP_MILLIS file column into the millisecond domain (exact integer rescaling of the literal, like Spark's ParquetFilters pushing predicates in the file's physical unit), plus IS NULL/IS NOT NULL unwrapping. Pruning works again, predicate evaluation never converts file values, and the scan output conversion stays checked, so values actually read still fail like millisToMicros.

Added your filtered case as a regression test over dictionary × ANSI × rowFilterPushdown (8 configs), and native unit tests pinning the rounding table for all six comparison operators, both operand orders, and negative/sub-millisecond literals.

One deliberate divergence to note: with row-filter pushdown on and a non-pruned row group, rows the filter discards are no longer converted, so Comet can succeed where Spark (which converts the whole row group during decode) throws — the benign direction of "errors only for values actually read." (d465192)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve pruning for IN, null-safe equality, and nested timestamp predicates

Rechecked 0d78875: the original ts < epoch reproducer now passes, but this issue remains for three supported predicate forms. With 32 repeated 9223372036854776 millisecond values in a TIMESTAMP_MILLIS column (and the same value in nested s.ts), these predicates return zero rows in Spark 4.1.3 and base c067e4e, while the PR head throws Overflow happened on: 9223372036854776 * 1000:

ts IN (TIMESTAMP '1970-01-01 00:00:00', TIMESTAMP '1970-01-02 00:00:00')
ts <=> TIMESTAMP '1970-01-01 00:00:00'
s.ts < TIMESTAMP '1970-01-01 00:00:00'

All 24 cases reproduce across plain/dictionary encoding, ANSI on/off, and row-filter pushdown on/off; dictionary encoding was verified from the footer. The eight ordinary < controls pass on all three builds.

The new rewrite skips InListExpr, does not handle IsNotDistinctFrom, and only matches a direct CometCastColumnExpr, so the nested-field predicate also retains the opaque conversion. These row groups are still read and converted even though Spark prunes them.

Could we extend pruning to these forms before checked conversion and add regression coverage? This is the failing-query direction of the original issue, separate from the documented case where Comet skips an error on a row discarded by its filter.

Validation: independent native Parquet scans plus focused Spark 4.1.3/JDK17 comparisons. The base control reused a hash-verified c067e4e native library; production JVM/proto sources are identical. Other Spark versions were not run locally.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified all three and fixed. IN and <=> now rewrite into the millisecond domain (list elements rescale with IN's null semantics preserved; null-safe equality rescales or folds to a constant, and <=> NULL becomes IS NULL) — I confirmed DataFusion 54.1's PruningPredicate analyzes both InListExpr and IsNotDistinctFrom, so pruning covers them in every config.

The nested case turned out deeper than the rewrite: DataFusion can neither prune nested-field predicates (pruning_predicate.rs — "PruningPredicate does not support pruning on nested fields yet") nor evaluate them as row filters (can_expr_be_pushed_down_with_schemas classifies struct columns non-pushable), and the failing conversion was actually the flat ts column being materialized from row groups nothing could prune. Since Spark only avoids the error via nested statistics pruning we don't have, scans whose data filters reference nested fields now fall back to the safe conversion (overflow → NULL, main's behavior) and the filter discards the rows — matching Spark's zero-row answers across your matrix. Checked conversion stays for all other scans, and is scoped to top-level columns for the same reason.

Extended the regression test to your three forms plus the flat control (4 predicates × dictionary × ANSI × rowFilterPushdown), and added native tests for the IN/null-safe rewrites and the top-level/nested/flag-off conversion split. Remaining divergence, documented in the code: a nested-predicate scan Spark fails to prune errors in Spark but NULLs in Comet, as does a direct nested read of overflow — both pre-existing behavior. Filed #5553 to lift both once DataFusion grows nested-field pruning. (9172861)

Ok(Arc::new(micros))
}
(Timestamp(TimeUnit::Microsecond, None), Timestamp(TimeUnit::Microsecond, Some(tz))) => {
Ok(Arc::new(
array
Expand Down Expand Up @@ -300,10 +344,11 @@ fn parquet_convert_struct_to_struct(
};

if let Some(from_index) = from_index {
cast_fields.push(parquet_convert_array(
cast_fields.push(parquet_convert_array_impl(
Arc::clone(array.column(from_index)),
to_field.data_type(),
parquet_options,
false,
)?);
field_overlap = true;
} else {
Expand Down Expand Up @@ -351,15 +396,17 @@ fn parquet_convert_map_to_map(
"map is missing value field".to_string(),
))?;

let key_array = parquet_convert_array(
let key_array = parquet_convert_array_impl(
Arc::clone(from.keys()),
key_field.data_type(),
parquet_options,
false,
)?;
let value_array = parquet_convert_array(
let value_array = parquet_convert_array_impl(
Arc::clone(from.values()),
value_field.data_type(),
parquet_options,
false,
)?;

Ok(Arc::new(MapArray::new(
Expand Down Expand Up @@ -656,4 +703,72 @@ mod tests {
}
}
}

#[test]
fn test_millis_to_micros_overflow_checked_only_at_top_level() {
use crate::parquet::parquet_support::{parquet_convert_array, SparkParquetOptions};
use arrow::array::{Array, ArrayRef, StructArray, TimestampMillisecondArray};
use arrow::datatypes::{DataType, Field, Fields, TimeUnit};
use datafusion_comet_spark_expr::EvalMode;
use std::sync::Arc;

let options = SparkParquetOptions::new(EvalMode::Legacy, "UTC", false);
let overflow_millis = 9_223_372_036_854_776_i64;
let millis: ArrayRef = Arc::new(TimestampMillisecondArray::from(vec![
Some(overflow_millis),
None,
]));
let micros_type = DataType::Timestamp(TimeUnit::Microsecond, None);

// Top-level: checked, matching Spark's `millisToMicros` (`Math.multiplyExact`).
let err = parquet_convert_array(Arc::clone(&millis), &micros_type, &options)
.expect_err("top-level overflow must error");
assert!(
err.to_string().to_lowercase().contains("overflow"),
"unexpected error: {err}"
);

// Scans whose data filters reference nested fields disable the checked
// conversion (DataFusion cannot prune or row-filter those predicates the way
// Spark's statistics pruning protects them), falling back to overflow -> NULL.
let mut unchecked_options = options.clone();
unchecked_options.checked_timestamp_overflow = false;
let converted =
parquet_convert_array(Arc::clone(&millis), &micros_type, &unchecked_options)
.expect("unchecked overflow must not error");
assert!(converted.is_null(0), "overflow must become NULL");
assert!(converted.is_null(1));

// Nested: DataFusion's PruningPredicate cannot prune nested fields, so a
// checked conversion would fail queries whose predicates Spark satisfies via
// row-group statistics pruning. The nested field keeps the safe-cast behavior:
// overflow becomes NULL.
let child_field = Arc::new(Field::new(
"ts",
DataType::Timestamp(TimeUnit::Millisecond, None),
true,
));
let strukt: ArrayRef = Arc::new(StructArray::new(
Fields::from(vec![Arc::clone(&child_field)]),
vec![millis],
None,
));
let target = DataType::Struct(Fields::from(vec![Arc::new(Field::new(
"ts",
micros_type.clone(),
true,
))]));
let converted = parquet_convert_array(strukt, &target, &options)
.expect("nested overflow must not error");
let converted_child = Arc::clone(
converted
.as_any()
.downcast_ref::<StructArray>()
.unwrap()
.column(0),
);
assert_eq!(converted_child.data_type(), &micros_type);
assert!(converted_child.is_null(0), "overflow must become NULL");
assert!(converted_child.is_null(1));
}
}
Loading
Loading