Skip to content

Commit e078fed

Browse files
committed
feat: honor reader timezone in partition values
1 parent 892685d commit e078fed

18 files changed

Lines changed: 1095 additions & 368 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

datafusion-executor/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/user-guide/src/reading/scan_metadata.md

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -249,9 +249,9 @@ If the scan has no predicate, this returns `None`.
249249
## Typed partition values
250250

251251
Kernel reads each file's partition values from the Delta log and exposes them on its
252-
`ScanFile` as a raw string map. Kernel's `transform_to_logical` could materialize them as
253-
typed columns. If your connector assembles output rows itself instead of using that transform,
254-
it parses the string map per file.
252+
`ScanFile` as a raw string map. `transform_to_logical` materializes them as typed columns. A
253+
connector that assembles output rows itself instead of using that transform would otherwise need
254+
to parse the string map per file.
255255

256256
To have Kernel hand you the typed values directly, opt in with `with_partition_values`:
257257

@@ -270,7 +270,10 @@ To have Kernel hand you the typed values directly, opt in with `with_partition_v
270270
# let snapshot = Snapshot::builder_for(url).build(&engine)?;
271271
let scan = snapshot
272272
.scan_builder()
273-
.with_partition_values(PartitionValuesOptions::with_struct())
273+
.with_partition_values(
274+
PartitionValuesOptions::with_struct()
275+
.with_timestamp_timezone("America/Los_Angeles"),
276+
)
274277
.build()?;
275278
# Ok(())
276279
# }
@@ -281,9 +284,17 @@ nullable field per partition column (by physical name). You read it as a typed c
281284
of parsing the string map per file. The raw string map is still present, so this option only
282285
adds the typed column.
283286

287+
By default, offset-less `TIMESTAMP` partition strings are interpreted in UTC. Use
288+
`with_timestamp_timezone` with an IANA timezone or fixed offset when the reader uses another
289+
timezone. An explicit offset in a partition value takes precedence. This setting affects typed
290+
`scan_metadata` output, internal partition pruning, and the partition-column row transforms used
291+
by `Scan::execute`. Incremental scans expose the raw partition-value map. For daylight-saving
292+
transitions, ambiguous local times use the earlier instant, and nonexistent local times use the
293+
offset from before the transition.
294+
284295
> [!TIP]
285-
> When the checkpoint already stores typed partition values, Kernel reads that column directly
286-
> and skips parsing entirely.
296+
> Kernel parses typed partition values from the raw map for both commits and checkpoints. This
297+
> keeps reader-timezone behavior independent of the timezone used to write a checkpoint.
287298
288299
## Cancelling a scan
289300

kernel/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ pre-release-hook = [
4242
delta_kernel_derive = { path = "../derive-macros", version = "0.27.1" }
4343
bytes = "1.10"
4444
chrono = "0.4.41"
45+
chrono-tz = "0.10.4"
4546
crc = "3.2.2"
4647
indexmap = "2.10.0"
4748
itertools = "0.14"

kernel/src/checkpoint/checkpoint_shape.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,6 @@
33
//! When stats are requested, also reports whether the checkpoint has compatible parsed stats.
44
//! Driven through a [`PlanExecutor`].
55
6-
// No in-crate caller yet; following PRs will use this.
7-
#![allow(dead_code)]
8-
96
use url::Url;
107

118
use crate::actions::visitors::SidecarVisitor;

kernel/src/engine/arrow_expression/evaluate_expression.rs

Lines changed: 1 addition & 159 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,16 @@ use std::borrow::Cow;
33
use std::collections::HashMap;
44
use std::sync::Arc;
55

6-
use chrono::{FixedOffset, NaiveDateTime, Offset, TimeDelta, TimeZone, Utc};
76
use itertools::Itertools;
87
use tracing::warn;
98

10-
use crate::arrow::array::timezone::Tz;
119
use crate::arrow::array::types::*;
1210
use crate::arrow::array::{
1311
self as arrow_array, make_array, new_null_array, Array, ArrayBuilder, ArrayData, ArrayRef,
1412
AsArray, BooleanArray, Datum, ListArray, MapArray, MutableArrayData, NullBufferBuilder,
1513
RecordBatch, StringArray, StructArray,
1614
};
1715
use crate::arrow::buffer::{NullBuffer, OffsetBuffer};
18-
use crate::arrow::compute::kernels::cast_utils::{string_to_datetime, Parser};
1916
use crate::arrow::compute::kernels::cmp::{distinct, eq, gt, gt_eq, lt, lt_eq, neq, not_distinct};
2017
use crate::arrow::compute::kernels::comparison::in_list_utf8;
2118
use crate::arrow::compute::kernels::numeric::{add, div, mul, sub};
@@ -44,6 +41,7 @@ use crate::expressions::{
4441
UnaryPredicateOp, VariadicExpression, VariadicExpressionOp,
4542
};
4643
use crate::schema::{DataType, PrimitiveType, StructField, StructType};
44+
use crate::timestamp_timezone::{parse_partition_scalar, TimestampTimezone};
4745

4846
#[internal_api]
4947
pub(crate) trait ProvidesColumnByName {
@@ -414,95 +412,6 @@ pub fn evaluate_expression(
414412
}
415413
}
416414

417-
#[derive(Clone, Copy)]
418-
enum TimestampTimezone {
419-
Arrow(Tz),
420-
Fixed(FixedOffset),
421-
}
422-
423-
impl TimestampTimezone {
424-
fn parse(value: &str) -> DeltaResult<Self> {
425-
if value.starts_with('+') || value.starts_with('-') {
426-
return parse_fixed_offset(value)
427-
.map(Self::Fixed)
428-
.ok_or_else(|| Error::generic(format!("Invalid timestamp timezone: {value}")));
429-
}
430-
value
431-
.parse::<Tz>()
432-
.map(Self::Arrow)
433-
.map_err(|_| Error::generic(format!("Invalid timestamp timezone: {value}")))
434-
}
435-
436-
fn parse_timestamp(self, raw: &str) -> Result<i64, ArrowError> {
437-
match self {
438-
Self::Arrow(timezone) => parse_timestamp_with_timezone(timezone, raw),
439-
Self::Fixed(timezone) => parse_timestamp_with_timezone(timezone, raw),
440-
}
441-
}
442-
}
443-
444-
fn parse_fixed_offset(value: &str) -> Option<FixedOffset> {
445-
let (sign, value) = match value.as_bytes().first()? {
446-
b'+' => (1, &value[1..]),
447-
b'-' => (-1, &value[1..]),
448-
_ => return None,
449-
};
450-
let mut parts = value.split(':');
451-
let parse_component = |part: &str| {
452-
(!part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
453-
.then(|| part.parse::<i32>().ok())
454-
.flatten()
455-
};
456-
let hours = parse_component(parts.next()?)?;
457-
let minutes = parts.next().map_or(Some(0), parse_component)?;
458-
let seconds = parts.next().map_or(Some(0), parse_component)?;
459-
if parts.next().is_some()
460-
|| value.is_empty()
461-
|| hours > 18
462-
|| minutes > 59
463-
|| seconds > 59
464-
|| (hours == 18 && (minutes != 0 || seconds != 0))
465-
{
466-
return None;
467-
}
468-
FixedOffset::east_opt(sign * (hours * 3_600 + minutes * 60 + seconds))
469-
}
470-
471-
fn parse_timestamp_with_timezone<T: TimeZone + Copy>(
472-
timezone: T,
473-
raw: &str,
474-
) -> Result<i64, ArrowError> {
475-
if let Ok(timestamp) = string_to_datetime(&timezone, raw) {
476-
return Ok(timestamp.timestamp_micros());
477-
}
478-
let local_datetime = string_to_datetime(&Utc, raw)?.naive_utc();
479-
resolve_local_timestamp(local_datetime, timezone).ok_or_else(|| {
480-
ArrowError::ParseError(format!("Error resolving local timestamp from '{raw}'"))
481-
})
482-
}
483-
484-
fn resolve_local_timestamp<T: TimeZone + Copy>(
485-
local_datetime: NaiveDateTime,
486-
timezone: T,
487-
) -> Option<i64> {
488-
if let Some(timestamp) = timezone.from_local_datetime(&local_datetime).earliest() {
489-
return Some(timestamp.timestamp_micros());
490-
}
491-
492-
// A forward transition can skip more than one hour, so walk backward until reaching the
493-
// pre-transition side of the gap.
494-
let offset = (1..=48).find_map(|hours| {
495-
let before_transition = local_datetime.checked_sub_signed(TimeDelta::hours(hours))?;
496-
timezone
497-
.from_local_datetime(&before_transition)
498-
.earliest()
499-
.map(|timestamp| timestamp.offset().fix())
500-
})?;
501-
local_datetime
502-
.checked_sub_signed(TimeDelta::seconds(i64::from(offset.local_minus_utc())))
503-
.map(|utc_datetime| utc_datetime.and_utc().timestamp_micros())
504-
}
505-
506415
/// Evaluate an `ARRAY(e0, e1, ..., eN-1)` constructor expression into an Arrow `ListArray`.
507416
///
508417
/// Each input expression produces one column of length M (rows in the batch); the output
@@ -1032,44 +941,6 @@ pub fn coalesce_arrays(
1032941
Ok(make_array(mutable.freeze()))
1033942
}
1034943

1035-
/// Parses one raw partition-value string into its target [`Scalar`], or `None` for a null value.
1036-
///
1037-
/// An empty string casts via [`PrimitiveType::empty_string_partition_cast`].
1038-
///
1039-
/// Uses map-to-struct's date and timestamp semantics.
1040-
fn parse_partition_scalar(
1041-
prim: &PrimitiveType,
1042-
raw: &str,
1043-
timestamp_timezone: TimestampTimezone,
1044-
) -> DeltaResult<Option<Scalar>> {
1045-
if raw.is_empty() {
1046-
return Ok(prim.empty_string_partition_cast());
1047-
}
1048-
match prim {
1049-
PrimitiveType::Date => {
1050-
let days = Date32Type::parse(raw).ok_or_else(|| {
1051-
Error::ParseError(raw.to_string(), DataType::Primitive(prim.clone()))
1052-
})?;
1053-
return Ok(Some(Scalar::Date(days)));
1054-
}
1055-
PrimitiveType::Timestamp => {
1056-
let micros = timestamp_timezone.parse_timestamp(raw).map_err(|_| {
1057-
Error::ParseError(raw.to_string(), DataType::Primitive(prim.clone()))
1058-
})?;
1059-
return Ok(Some(Scalar::Timestamp(micros)));
1060-
}
1061-
PrimitiveType::TimestampNtz => {
1062-
let micros = string_to_datetime(&Utc, raw)
1063-
.map_err(|_| Error::ParseError(raw.to_string(), DataType::Primitive(prim.clone())))?
1064-
.timestamp_micros();
1065-
return Ok(Some(Scalar::TimestampNtz(micros)));
1066-
}
1067-
_ => {}
1068-
}
1069-
let scalar = prim.parse_scalar(raw)?;
1070-
Ok((!matches!(scalar, Scalar::Null(_))).then_some(scalar))
1071-
}
1072-
1073944
/// Evaluates `MAP_TO_STRUCT(map_col, output_schema)`: extracts keys from a `Map<String, String>`
1074945
/// and parses each value into its target type, producing a `StructArray`. An empty-string value
1075946
/// casts via [`PrimitiveType::empty_string_partition_cast`].
@@ -3008,35 +2879,6 @@ mod tests {
30082879
);
30092880
}
30102881

3011-
#[rstest]
3012-
#[case::zero("+0", 0)]
3013-
#[case::hours("+5", 18_000)]
3014-
#[case::hours_minutes("-05:30", -19_800)]
3015-
#[case::hours_minutes_seconds("+12:45:30", 45_930)]
3016-
#[case::positive_limit("+18:00:00", 64_800)]
3017-
#[case::negative_limit("-18", -64_800)]
3018-
fn test_parse_fixed_offset_accepts_supported_forms(
3019-
#[case] timezone: &str,
3020-
#[case] expected_seconds: i32,
3021-
) {
3022-
assert_eq!(
3023-
parse_fixed_offset(timezone).map(|offset| offset.local_minus_utc()),
3024-
Some(expected_seconds)
3025-
);
3026-
}
3027-
3028-
#[rstest]
3029-
#[case::empty("")]
3030-
#[case::bare_positive_sign("+")]
3031-
#[case::over_max_second("+18:00:01")]
3032-
#[case::over_max_hour("+19")]
3033-
#[case::minutes_out_of_range("+00:60")]
3034-
#[case::seconds_out_of_range("+00:00:60")]
3035-
#[case::extra_component("+05:30:00:00")]
3036-
fn test_parse_fixed_offset_rejects_invalid_forms(#[case] timezone: &str) {
3037-
assert_eq!(parse_fixed_offset(timezone), None);
3038-
}
3039-
30402882
#[test]
30412883
fn test_map_to_struct_timestamp_timezone_does_not_affect_timestamp_ntz() {
30422884
let raw = "2024-01-15 12:30:45.123456";

kernel/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ pub mod table_changes;
114114
pub mod table_configuration;
115115
pub mod table_features;
116116
pub mod table_properties;
117+
mod timestamp_timezone;
117118
pub mod transaction;
118119
pub mod transforms;
119120

0 commit comments

Comments
 (0)