Skip to content

Commit 3244992

Browse files
committed
improve Spark from_utc_timestamp compatibility
The timezone spellings accepted by Spark are different than those accepted by Arrow's parser. As a result, `from_utc_timestamp` in comet isn't safe to use, since it would fail on things like Java short IDs, prefixed offsets with second precision, and SystemV-style IDs. Additionally, Arrow will accept offsets beyond the Spark limit of 18 hours. This change improves compatibility by parsing timezone IDs with the rules implemented by Spark. As part of doing this, I added SparkFromUtcTimestampExpr so that computed timezones evaluated for rows with non-null timestamps, avoiding failing when a timezone is invalid but wouldn't be required anyway. (Full compatibility with spark behaviour seems quite complex and I haven't attempted it here.) It also adds more documentation recording remaining differences: - timezone validation and evaluation order can differ from Spark - chrono accepts a narrower calendar range than Spark implemented range - chrono-tz doesn't currently perform DST transitions past 2099 This does not achieve full compatibility with Spark due to those issues, but it makes the region of incompatibility a lot smaller.
1 parent c922f88 commit 3244992

8 files changed

Lines changed: 1149 additions & 22 deletions

File tree

‎datafusion/functions/src/datetime/to_local_time.rs‎

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
// specific language governing permissions and limitations
1616
// under the License.
1717

18-
use std::ops::Add;
1918
use std::sync::Arc;
2019

2120
use arrow::array::timezone::Tz;
@@ -30,8 +29,8 @@ use chrono::{DateTime, MappedLocalTime, Offset, TimeDelta, TimeZone, Utc};
3029

3130
use datafusion_common::cast::as_primitive_array;
3231
use datafusion_common::{
33-
Result, ScalarValue, exec_err, internal_datafusion_err, internal_err,
34-
utils::take_function_args,
32+
Result, ScalarValue, exec_datafusion_err, exec_err, internal_datafusion_err,
33+
internal_err, utils::take_function_args,
3534
};
3635
use datafusion_expr::{
3736
Coercion, ColumnarValue, Documentation, ScalarFunctionArgs, ScalarUDFImpl, Signature,
@@ -307,7 +306,10 @@ fn to_local_time(time_value: &ColumnarValue) -> Result<ColumnarValue> {
307306
/// ```
308307
///
309308
/// See `test_adjust_to_local_time()` for example
310-
pub fn adjust_to_local_time<T: ArrowTimestampType>(ts: i64, tz: Tz) -> Result<i64> {
309+
pub fn adjust_to_local_time<T: ArrowTimestampType>(
310+
ts: i64,
311+
tz: impl TimeZone + Copy,
312+
) -> Result<i64> {
311313
fn convert_timestamp<F>(ts: i64, converter: F) -> Result<DateTime<Utc>>
312314
where
313315
F: Fn(i64) -> MappedLocalTime<DateTime<Utc>>,
@@ -337,12 +339,18 @@ pub fn adjust_to_local_time<T: ArrowTimestampType>(ts: i64, tz: Tz) -> Result<i6
337339
.fix()
338340
.local_minus_utc() as i64;
339341

340-
let adjusted_date_time = date_time.add(
341-
// This should not fail under normal circumstances as the
342-
// maximum possible offset is 26 hours (93,600 seconds)
343-
TimeDelta::try_seconds(offset_seconds)
344-
.ok_or_else(|| internal_datafusion_err!("Offset seconds should be less than i64::MAX / 1_000 or greater than -i64::MAX / 1_000"))?,
345-
);
342+
let adjusted_date_time = date_time
343+
.checked_add_signed(
344+
// This should not fail under normal circumstances as the
345+
// maximum possible offset is 26 hours (93,600 seconds)
346+
TimeDelta::try_seconds(offset_seconds)
347+
.ok_or_else(|| internal_datafusion_err!("Offset seconds should be less than i64::MAX / 1_000 or greater than -i64::MAX / 1_000"))?,
348+
)
349+
.ok_or_else(|| {
350+
exec_datafusion_err!(
351+
"Timezone adjustment of timestamp {ts} by {offset_seconds} seconds exceeds Chrono's calendar range"
352+
)
353+
})?;
346354

347355
// convert the naive datetime back to i64
348356
match T::UNIT {

‎datafusion/spark/Cargo.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,7 @@ name = "sha2"
106106
[[bench]]
107107
harness = false
108108
name = "floor"
109+
110+
[[bench]]
111+
harness = false
112+
name = "from_utc_timestamp"
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
//! Measure the physical evaluator with a timezone column. The narrow and wide batches use
19+
//! identical timestamp and timezone columns; only the number of unused Int64 columns differs.
20+
//! Mixed-null timestamps expose any cost from filtering those unrelated columns.
21+
22+
use std::hint::black_box;
23+
use std::sync::Arc;
24+
25+
use arrow::array::{ArrayRef, Int64Array, StringArray, TimestampMicrosecondArray};
26+
use arrow::datatypes::{DataType, Field, Schema, TimeUnit};
27+
use arrow::record_batch::RecordBatch;
28+
use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
29+
use datafusion::physical_expr::expressions::Column;
30+
use datafusion_physical_expr_common::physical_expr::PhysicalExpr;
31+
use datafusion_spark::function::datetime::from_utc_timestamp::SparkFromUtcTimestampExpr;
32+
33+
fn batch(rows: usize, columns: usize, half_null: bool) -> RecordBatch {
34+
let mut fields = vec![
35+
Field::new(
36+
"timestamp",
37+
DataType::Timestamp(TimeUnit::Microsecond, None),
38+
true,
39+
),
40+
Field::new("timezone", DataType::Utf8, false),
41+
];
42+
let mut arrays: Vec<ArrayRef> = vec![
43+
Arc::new(TimestampMicrosecondArray::from_iter((0..rows).map(|row| {
44+
(!half_null || row % 2 != 0).then_some(row as i64 * 86_400_000_000)
45+
}))),
46+
Arc::new(StringArray::from_iter_values(
47+
(0..rows).map(|row| if (row / 2) % 2 == 0 { "PST" } else { "EST" }),
48+
)),
49+
];
50+
51+
for column in 2..columns {
52+
fields.push(Field::new(
53+
format!("unused_{column}"),
54+
DataType::Int64,
55+
false,
56+
));
57+
arrays.push(Arc::new(Int64Array::from_iter_values(
58+
(0..rows).map(|row| (column * rows + row) as i64),
59+
)));
60+
}
61+
62+
RecordBatch::try_new(Arc::new(Schema::new(fields)), arrays).unwrap()
63+
}
64+
65+
fn criterion_benchmark(c: &mut Criterion) {
66+
let rows = 8192;
67+
let expr = SparkFromUtcTimestampExpr::new(
68+
Arc::new(Column::new("timestamp", 0)),
69+
Arc::new(Column::new("timezone", 1)),
70+
);
71+
let mut group = c.benchmark_group("from_utc_timestamp_column");
72+
group.throughput(Throughput::Elements(rows as u64));
73+
74+
for columns in [2, 64, 256] {
75+
for (half_null, name) in [(false, "no_nulls"), (true, "half_nulls")] {
76+
let batch = batch(rows, columns, half_null);
77+
let result = expr.evaluate(&batch).unwrap().into_array(rows).unwrap();
78+
assert_eq!(result.len(), rows);
79+
assert_eq!(result.null_count(), if half_null { rows / 2 } else { 0 });
80+
81+
group.bench_with_input(
82+
BenchmarkId::new(format!("{columns}_columns"), name),
83+
&batch,
84+
|b, batch| {
85+
b.iter(|| black_box(expr.evaluate(black_box(batch)).unwrap()));
86+
},
87+
);
88+
}
89+
}
90+
group.finish();
91+
}
92+
93+
criterion_group!(benches, criterion_benchmark);
94+
criterion_main!(benches);

0 commit comments

Comments
 (0)