Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
125 changes: 104 additions & 21 deletions native/spark-expr/src/conversion_funcs/string.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1373,6 +1373,30 @@ pub(crate) fn ymd_to_epoch_day(year: i64, month: i64, day: i64) -> Option<i64> {
Some(days_from_civil(year, month, day))
}

/// chrono 0.4.45 NaiveDate year bounds (`MIN_YEAR` / `MAX_YEAR`).
const CHRONO_MIN_YEAR: i64 = -262143;
const CHRONO_MAX_YEAR: i64 = 262142;

/// Offset (local − UTC) in seconds at `naive` in `tz`.
///
/// Fall-back overlap picks the earlier (pre-transition) offset, matching Spark.
/// Spring-forward gaps probe three hours earlier with a checked subtract so extreme
/// NaiveDateTimes cannot panic; a second `None` yields `None` (caller may treat as UTC).
fn offset_secs_at<T: TimeZone>(tz: &T, naive: chrono::NaiveDateTime) -> Option<i64> {
match tz.from_local_datetime(&naive) {
LocalResult::Single(dt) => Some(dt.offset().fix().local_minus_utc() as i64),
LocalResult::Ambiguous(dt, _) => Some(dt.offset().fix().local_minus_utc() as i64),
LocalResult::None => {
let pre = naive.checked_sub_signed(chrono::Duration::hours(3))?;
match tz.from_local_datetime(&pre) {
LocalResult::Single(dt) => Some(dt.offset().fix().local_minus_utc() as i64),
LocalResult::Ambiguous(dt, _) => Some(dt.offset().fix().local_minus_utc() as i64),
LocalResult::None => None,
}
}
}
}

fn parse_timestamp_to_micros<T: TimeZone>(
timestamp_info: &TimeStampInfo,
tz: &T,
Expand Down Expand Up @@ -1411,14 +1435,15 @@ fn parse_timestamp_to_micros<T: TimeZone>(
// Java's ZonedDateTime.of() advances by the gap length, which is equivalent to
// utc = local_naive − pre_gap_offset
LocalResult::None => {
let probe = local_naive - chrono::Duration::hours(3);
let pre_offset = match tz.from_local_datetime(&probe) {
LocalResult::Single(dt) => dt.offset().fix(),
LocalResult::Ambiguous(dt, _) => dt.offset().fix(),
LocalResult::None => return Ok(None),
let offset_secs = match offset_secs_at(tz, local_naive) {
Some(s) => s,
None => return Ok(None),
};
let offset_secs = pre_offset.local_minus_utc() as i64;
let utc_naive = local_naive - chrono::Duration::seconds(offset_secs);
let utc_naive =
match local_naive.checked_sub_signed(chrono::Duration::seconds(offset_secs)) {
Some(n) => n,
None => return Ok(None),
};
Some(utc_naive.and_utc().timestamp_micros())
}
};
Expand All @@ -1427,13 +1452,14 @@ fn parse_timestamp_to_micros<T: TimeZone>(
} else {
// NaiveDate::from_ymd_opt returned None. This means either:
// (a) invalid calendar date (Feb 29 on non-leap year, month 13, etc.)
// (b) year outside chrono's representable range (> 262143 or < -262144)
// (b) year outside chrono's representable range (chrono 0.4.45: outside
// [-262143, 262142])
//
// For case (b) we fall back to Howard Hinnant's direct arithmetic, which works
// for any year that fits in i64. This covers the Long.MaxValue / Long.MinValue
// boundary timestamps (year 294247 / -290308).
let year = timestamp_info.year as i64;
if (-262144..=262143).contains(&year) {
if (CHRONO_MIN_YEAR..=CHRONO_MAX_YEAR).contains(&year) {
// Year is in chrono's range but date was rejected -> truly invalid date.
return Ok(None);
}
Expand All @@ -1443,18 +1469,22 @@ fn parse_timestamp_to_micros<T: TimeZone>(
Some(days) => days,
None => return Ok(None),
};
// Compute the timezone offset using epoch as a surrogate probe point.
// Extreme-year timestamps are only valid with a UTC-like fixed offset (any DST
// zone would overflow). Using epoch gives us the standard offset.
let epoch_probe = NaiveDate::from_ymd_opt(1970, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap();
let tz_offset_secs: i64 = match tz.from_local_datetime(&epoch_probe) {
LocalResult::Single(dt) => dt.offset().fix().local_minus_utc() as i64,
LocalResult::Ambiguous(dt, _) => dt.offset().fix().local_minus_utc() as i64,
LocalResult::None => 0,
};
// Look up the zone offset at the same local civil time on a year inside chrono's
// range that is congruent modulo the 400-year Gregorian cycle (leap days match).
// 1600 + rem_euclid(400) lands in 1600..=1999. For America/Los_Angeles far-past
// extremes this is still in the local-mean-time era (matching Spark's ZoneRules),
// instead of the standard -08:00 offset that probing 1970-01-01 would return.
// Fixed offsets and UTC are unchanged. Far-future DST after chrono-tz's last
// transition (~2099) is a separate gap (#6816).
let probe_year = (1600 + year.rem_euclid(400)) as i32;

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.

Could we choose the offset era separately for far-past and far-future dates? 1600 + year.rem_euclid(400) preserves the calendar but not timezone rules. For 262543-01-01 00:00:00 it probes 1743: the pinned chrono-tz returns Los Angeles offset -07:52:58 while Java/Spark uses -08:00, making the result 422 seconds early. Tokyo similarly returns +09:18:59 instead of +09:00, a 1,139-second error. The previous 1970 probe was correct for both January cases, so this is a new regression, separate from the known future-DST horizon issue #6816. In the other direction, -262500-01-01 probes 1900 and still misses the ancient LMT offset. I reproduced these offsets with the exact Rust dependencies and Java. Please cover both future January and negative-year residues that land after timezone transitions, and run the new Spark comparison test after correcting the probe. A native-cast assertion or expect_native(cast) SQL fixture would also ensure the regression test exercises this Rust path.

let probe_date =
NaiveDate::from_ymd_opt(probe_year, timestamp_info.month, timestamp_info.day)
.expect("month/day valid across a 400-year shift");
let probe_naive = probe_date.and_time(naive_time);
// None/Ambiguous are rare for this probe (it lands before every transition or
// after chrono-tz's last timespan for most zones), but the helper covers DST gaps
// if the congruent civil time falls in one. A second None falls back to UTC (0).
let tz_offset_secs = offset_secs_at(tz, probe_naive).unwrap_or(0);
// Compute seconds since epoch via direct calendar arithmetic.
// Use i128 for the intermediate multiply-by-1_000_000 step: the seconds value can be
// just outside the i64 range while the final microseconds result is still within range
Expand Down Expand Up @@ -3091,6 +3121,59 @@ mod tests {
);
}

#[test]
fn extreme_year_session_tz_uses_historic_offset() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The expected values in extreme_year_session_tz_uses_historic_offset are asserted from the author's reading of Spark and are not compared against it. CometDateTimeUtilsSuite.checkCastToTimestampAsLong already casts to LONG to avoid the toJavaTimestamp limits, and the SQL harness supports -- ConfigMatrix: spark.sql.session.timeZone=.... Could one case there use America/Los_Angeles and a positive-offset zone such as Asia/Tokyo with -290308-12-21 19:59:05.224192 and -262544-01-01 00:00:00, where the second also exercises the point above? A 422 second difference survives the truncation in cast(ts as bigint). The Rust test also repeats the UTC i64::MAX and i64::MIN assertions of extreme_year_boundary_test, and the 1800-06-01 case is outside the extreme branch. I did not verify which session zone the Scala suite runs under.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added extreme-year session TZ uses historic zone offset in CometDateTimeUtilsSuite, which runs checkCastToTimestampAsLong under America/Los_Angeles and Asia/Tokyo for -290308-12-21 19:59:05.224192, -262544-01-01 00:00:00, and chrono-bound neighbours — so expected values come from Spark, and the 422s LMT gap survives the Long cast. Trimmed the Rust session-TZ unit test to drop the UTC i64::MAX/MIN and 1800 cases that duplicated extreme_year_boundary_test / the in-range path; the LA micros pin and the new chrono-bound neighbour unit test remain.

(AI-assisted reply.)

// Session-TZ (no zone suffix) extremes must use the zone's historic /
// local-mean-time offset, not the 1970 standard offset. Spark's
// America/Los_Angeles ZoneRules use LMT -07:52:58 before 1883; probing
// 1970 gave -08:00 and was 422s off (issue #7210).
// Spark-backed coverage of these (and Asia/Tokyo) lives in
// CometDateTimeUtilsSuite; this unit test pins the micros value.
let la = &Tz::from_str("America/Los_Angeles").unwrap();
assert_eq!(
timestamp_parser("-290308-12-21 19:59:05.224192", EvalMode::Legacy, la, true).unwrap(),
Some(-9223372008476775808),
);
let plus2 = &Tz::from_str("+02:00").unwrap();
// Local = UTC+2, so the UTC instant is 2h earlier than the UTC-session parse.
assert_eq!(
timestamp_parser(
"294247-01-10 04:00:54.775807",
EvalMode::Legacy,
plus2,
true
)
.unwrap(),
Some(i64::MAX - 2 * 3600 * 1_000_000),
);
}

#[test]
fn extreme_year_chrono_bound_neighbours_do_not_panic() {
// chrono 0.4.45 accepts [-262143, 262142]. Years just outside that used to
// land on 262143 / -262144 after the old while-loop shift and panic in
// from_ymd_opt.expect. rem_euclid keeps the probe inside the range.
let utc = &Tz::from_str("UTC").unwrap();
for (s, year) in [
("262543-01-01", 262543i64),
("262542-01-01", 262542),
("262143-01-01", 262143),
("262142-01-01", 262142),
("-262544-01-01", -262544),
("-262543-01-01", -262543),
("-262144-01-01", -262144),
("-262143-01-01", -262143),
] {
let days = ymd_to_epoch_day(year, 1, 1).unwrap();
let expected = days.checked_mul(86400).unwrap().checked_mul(1_000_000).unwrap();
assert_eq!(
timestamp_parser(s, EvalMode::Legacy, utc, true).unwrap(),
Some(expected),
"{s}"
);
}
}

#[test]
fn test_leading_whitespace_t_hm() {
let tz = &Tz::from_str("UTC").unwrap();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,25 @@ class CometDateTimeUtilsSuite extends CometTestBase {
}
}

test("extreme-year session TZ uses historic zone offset") {
// Session-TZ casts of years outside chrono's NaiveDate range must use the zone's
// historic / LMT offset (not 1970 standard). Compare Comet to Spark via Long
// (seconds) so the 422s America/Los_Angeles LMT vs PST gap survives truncation
// and toJavaTimestamp range limits are avoided. Also covers years just outside
// chrono's [-262143, 262142] bounds that previously panicked in the native probe.
for (tz <- Seq("America/Los_Angeles", "Asia/Tokyo")) {
withSQLConf(SQLConf.SESSION_LOCAL_TIMEZONE.key -> tz) {
checkCastToTimestampAsLong(
Seq(
"-290308-12-21 19:59:05.224192",
"-262544-01-01 00:00:00",
"-262543-01-01 00:00:00",
"262542-01-01 00:00:00",
"262543-01-01 00:00:00"))
}
}
}

test("SPARK-37326: cast string to TIMESTAMP_NTZ rejects timezone offsets") {
checkCastToTimestampNTZ(
Seq(
Expand Down
Loading