From 518c825c8c389c178facf4e3a533341544cbe56c Mon Sep 17 00:00:00 2001 From: Chong Gao Date: Thu, 2 Apr 2026 17:34:45 +0800 Subject: [PATCH 01/22] Support DST timezones for ORC Signed-off-by: Chong Gao --- src/main/cpp/src/GpuTimeZoneDBJni.cpp | 55 +- src/main/cpp/src/timezones.cu | 642 +++++++++++---- src/main/cpp/src/timezones.hpp | 66 +- .../spark/rapids/jni/GpuTimeZoneDB.java | 153 ++-- .../spark/rapids/jni/OrcTimezoneInfo.java | 738 ++++++++++++++---- src/main/resources/orc_timezone_info.data | Bin 544076 -> 0 bytes .../spark/rapids/jni/GpuTimeZoneDBTest.java | 370 ++++++++- 7 files changed, 1620 insertions(+), 404 deletions(-) delete mode 100644 src/main/resources/orc_timezone_info.data diff --git a/src/main/cpp/src/GpuTimeZoneDBJni.cpp b/src/main/cpp/src/GpuTimeZoneDBJni.cpp index 00e2aee494..4f09ccad3d 100644 --- a/src/main/cpp/src/GpuTimeZoneDBJni.cpp +++ b/src/main/cpp/src/GpuTimeZoneDBJni.cpp @@ -97,14 +97,53 @@ Java_com_nvidia_spark_rapids_jni_GpuTimeZoneDB_convertTimestampColumnToUTCWithTz JNI_CATCH(env, 0); } +static spark_rapids_jni::dst_rule parse_dst_rule(JNIEnv* env, jintArray jrule) +{ + spark_rapids_jni::dst_rule rule{}; + if (jrule == nullptr) { + rule.has_dst = false; + return rule; + } + constexpr jsize expected_dst_rule_length = 13; + auto const rule_length = env->GetArrayLength(jrule); + JNI_ARG_CHECK( + env, rule_length == expected_dst_rule_length, "dst rule array must have 13 ints", rule); + rule.has_dst = true; + jint* arr = env->GetIntArrayElements(jrule, nullptr); + // Order must match GpuTimeZoneDB.dstRuleToArray(): + // dstSavings, startMonth, startDay, startDayOfWeek, startTime, + // startTimeMode, startMode, endMonth, endDay, endDayOfWeek, + // endTime, endTimeMode, endMode + rule.dst_savings = arr[0]; + rule.start_month = arr[1]; + rule.start_day = arr[2]; + rule.start_dow = arr[3]; + rule.start_time = arr[4]; + rule.start_time_mode = arr[5]; + rule.start_mode = arr[6]; + rule.end_month = arr[7]; + rule.end_day = arr[8]; + rule.end_dow = arr[9]; + rule.end_time = arr[10]; + rule.end_time_mode = arr[11]; + rule.end_mode = arr[12]; + env->ReleaseIntArrayElements(jrule, arr, JNI_ABORT); + return rule; +} + JNIEXPORT jlong JNICALL Java_com_nvidia_spark_rapids_jni_GpuTimeZoneDB_convertOrcTimezones(JNIEnv* env, jclass, jlong input_handle, + jlong base_offset_us, jlong writer_tz_info_table, + jint writer_tz_initial_offset, jint writer_tz_raw_offset, + jintArray writer_dst_rule, jlong reader_tz_info_table, - jint reader_tz_raw_offset) + jint reader_tz_initial_offset, + jint reader_tz_raw_offset, + jintArray reader_dst_rule) { JNI_NULL_CHECK(env, input_handle, "input column is null", 0); @@ -114,9 +153,19 @@ Java_com_nvidia_spark_rapids_jni_GpuTimeZoneDB_convertOrcTimezones(JNIEnv* env, auto const input = reinterpret_cast(input_handle); auto const writer_tz_info_tab = reinterpret_cast(writer_tz_info_table); auto const reader_tz_info_tab = reinterpret_cast(reader_tz_info_table); + auto const writer_dst = parse_dst_rule(env, writer_dst_rule); + auto const reader_dst = parse_dst_rule(env, reader_dst_rule); return cudf::jni::ptr_as_jlong( - spark_rapids_jni::convert_orc_writer_reader_timezones( - *input, writer_tz_info_tab, writer_tz_raw_offset, reader_tz_info_tab, reader_tz_raw_offset) + spark_rapids_jni::convert_orc_writer_reader_timezones(*input, + static_cast(base_offset_us), + writer_tz_info_tab, + writer_tz_initial_offset, + writer_tz_raw_offset, + writer_dst, + reader_tz_info_tab, + reader_tz_initial_offset, + reader_tz_raw_offset, + reader_dst) .release()); } JNI_CATCH(env, 0); diff --git a/src/main/cpp/src/timezones.cu b/src/main/cpp/src/timezones.cu index 1c7a06735c..c688d5b374 100644 --- a/src/main/cpp/src/timezones.cu +++ b/src/main/cpp/src/timezones.cu @@ -27,6 +27,7 @@ #include #include #include +#include #include #include @@ -242,150 +243,487 @@ std::unique_ptr convert_to_utc_with_multiple_timezones( // =================== ORC timezones begin =================== // ORC timezone uses java.util.TimeZone rules, which is different from java.time.ZoneId rules. +// ---- Calendar helpers for DST computation on GPU ---- +// Ported from java.util.SimpleTimeZone.getOffset logic. + +constexpr int32_t MS_PER_SECOND = 1000; +constexpr int32_t MS_PER_MINUTE = 60 * MS_PER_SECOND; +constexpr int32_t MS_PER_HOUR = 60 * MS_PER_MINUTE; +constexpr int64_t MS_PER_DAY = 24LL * MS_PER_HOUR; + +// Cumulative days before each month (non-leap). Index 0 = Jan. +__device__ constexpr int32_t DAYS_BEFORE_MONTH[] = { + 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334}; + +__device__ static bool is_leap_year(int32_t year) +{ + return (year % 4 == 0) && ((year % 100 != 0) || (year % 400 == 0)); +} + +__device__ static int32_t days_in_month(int32_t month, int32_t year) +{ + int32_t d = + (month < 11) ? (DAYS_BEFORE_MONTH[month + 1] - DAYS_BEFORE_MONTH[month]) : 31; // December + if (month == 1 && is_leap_year(year)) { d++; } + return d; +} + +__device__ static int32_t days_before_month(int32_t month, int32_t year) +{ + int32_t d = DAYS_BEFORE_MONTH[month]; + if (month > 1 && is_leap_year(year)) { d++; } + return d; +} + +// Days from epoch (1970-01-01) to Jan 1 of the given year. +__device__ static int64_t days_from_epoch_to_year(int32_t year) +{ + int32_t y = year - 1; + // Gregorian calendar days from epoch + return 365LL * (year - 1970) + (y / 4 - 492) - (y / 100 - 19) + (y / 400 - 4); +} + +// DST rule mode constants (same as SimpleTimeZone) +constexpr int32_t DOM_MODE = 0; +constexpr int32_t DOW_IN_MONTH_MODE = 1; +constexpr int32_t DOW_GE_DOM_MODE = 2; +constexpr int32_t DOW_LE_DOM_MODE = 3; + +// Time mode constants +constexpr int32_t WALL_TIME = 0; +constexpr int32_t STANDARD_TIME = 1; +constexpr int32_t UTC_TIME = 2; + +/** + * @brief Compute the day-of-month when a DST rule triggers for the given year and month. + * + * Implements the same logic as SimpleTimeZone's rule decoding: + * - DOM_MODE: exact day of month + * - DOW_IN_MONTH_MODE: nth occurrence of dayOfWeek (negative = from end) + * - DOW_GE_DOM_MODE: first dayOfWeek on or after the given day + * - DOW_LE_DOM_MODE: last dayOfWeek on or before the given day + */ +__device__ static int32_t compute_rule_day( + int32_t rule_mode, int32_t rule_day, int32_t rule_dow, int32_t year, int32_t month) +{ + int32_t month_len = days_in_month(month, year); + + // Compute day-of-week of the 1st of the month + int64_t first_of_month_epoch_days = + days_from_epoch_to_year(year) + days_before_month(month, year); + int64_t dow_raw = ((first_of_month_epoch_days + 4) % 7); + if (dow_raw < 0) dow_raw += 7; + int32_t first_dow = static_cast(dow_raw) + 1; // 1=Sun..7=Sat + + switch (rule_mode) { + case DOM_MODE: return rule_day; + + case DOW_IN_MONTH_MODE: { + if (rule_day > 0) { + // nth occurrence: 1st=first week, etc. + int32_t diff = rule_dow - first_dow; + if (diff < 0) diff += 7; + return 1 + diff + (rule_day - 1) * 7; + } else { + // negative: from end of month. -1 = last occurrence. + int32_t last_dow = ((first_dow - 1 + (month_len - 1)) % 7) + 1; + int32_t diff = last_dow - rule_dow; + if (diff < 0) diff += 7; + return month_len - diff + (rule_day + 1) * 7; + } + } + + case DOW_GE_DOM_MODE: { + // First rule_dow on or after rule_day + int64_t target_epoch = first_of_month_epoch_days + (rule_day - 1); + int64_t target_raw = ((target_epoch + 4) % 7); + if (target_raw < 0) target_raw += 7; + int32_t target_dow = static_cast(target_raw) + 1; + int32_t diff = rule_dow - target_dow; + if (diff < 0) diff += 7; + return rule_day + diff; + } + + case DOW_LE_DOM_MODE: { + // Last rule_dow on or before rule_day + int64_t target_epoch = first_of_month_epoch_days + (rule_day - 1); + int64_t target_raw = ((target_epoch + 4) % 7); + if (target_raw < 0) target_raw += 7; + int32_t target_dow = static_cast(target_raw) + 1; + int32_t diff = target_dow - rule_dow; + if (diff < 0) diff += 7; + return rule_day - diff; + } + + default: return rule_day; + } +} + +/** + * @brief Compute the UTC millis of a DST transition for a given year. + * + * @param year The calendar year. + * @param rule_month 0-based month. + * @param rule_day Day parameter of the rule. + * @param rule_dow Day-of-week parameter. + * @param rule_time Time-of-day in ms. + * @param rule_time_mode WALL_TIME / STANDARD_TIME / UTC_TIME. + * @param rule_mode DOM / DOW_IN_MONTH / DOW_GE_DOM / DOW_LE_DOM. + * @param raw_offset_ms The timezone raw offset in ms. + * @param dst_savings_ms The DST savings in ms (needed for WALL_TIME conversion). + * @param is_start_rule True for DST start (to determine WALL_TIME adjustment). + */ +__device__ static int64_t compute_transition_utc_ms(int32_t year, + int32_t rule_month, + int32_t rule_day, + int32_t rule_dow, + int32_t rule_time, + int32_t rule_time_mode, + int32_t rule_mode, + int32_t raw_offset_ms, + int32_t dst_savings_ms, + bool is_start_rule) +{ + int32_t actual_day = compute_rule_day(rule_mode, rule_day, rule_dow, year, rule_month); + int64_t epoch_days = + days_from_epoch_to_year(year) + days_before_month(rule_month, year) + (actual_day - 1); + int64_t utc_ms = epoch_days * MS_PER_DAY + rule_time; + + // Convert from the specified time mode to UTC + switch (rule_time_mode) { + case WALL_TIME: + utc_ms -= raw_offset_ms; + // Wall time during DST-end means DST is still active, subtract savings. + // Wall time during DST-start means DST is not yet active. + if (!is_start_rule) { utc_ms -= dst_savings_ms; } + break; + case STANDARD_TIME: utc_ms -= raw_offset_ms; break; + case UTC_TIME: break; + } + + return utc_ms; +} + +// Lightweight year extraction from epoch millis (no month/day computation). +__device__ static int32_t millis_to_year(int64_t epoch_ms) +{ + int64_t day_count = + (epoch_ms >= 0) ? epoch_ms / MS_PER_DAY : (epoch_ms - MS_PER_DAY + 1) / MS_PER_DAY; + int64_t days_since_1 = day_count + 719468; + int32_t era = + static_cast((days_since_1 >= 0 ? days_since_1 : days_since_1 - 146096) / 146097); + int32_t doe = static_cast(days_since_1 - static_cast(era) * 146097); + int32_t yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + int32_t year = yoe + era * 400; + int32_t doy = doe - (365 * yoe + yoe / 4 - yoe / 100 + yoe / 400); + int32_t mp = (5 * doy + 2) / 153; + int32_t month = mp < 10 ? mp + 2 : mp - 10; + if (month <= 1) { year++; } + return year; +} + /** - * @brief Get the transition index for the given time `time_ms` using binary search. - * Find the first transition that is greater or equal to `time_ms`, - * and return the corresponding offset. + * @brief Compute the total UTC offset (raw + DST) for a UTC timestamp using DST rules. + * + * This is the GPU equivalent of java.util.SimpleTimeZone.getOffset(long). + * It computes the DST start and end transitions for the year containing the + * timestamp, then checks if the timestamp falls within the DST window. * - * @param begin the beginning of the transition array. - * @param end the end of the transition array. - * @param time_ms the input time in milliseconds to find the transition index for. - * @param offset_begin the beginning of the offset array. - * @param offset_end the end of the offset array. + * Handles both Northern Hemisphere (start < end) and Southern Hemisphere + * (start > end, i.e., DST spans year boundary). + */ +__device__ static int32_t compute_dst_offset(int64_t utc_ms, + int32_t raw_offset_ms, + spark_rapids_jni::dst_rule const& rule) +{ + if (!rule.has_dst) { return raw_offset_ms; } + + int32_t year = millis_to_year(utc_ms + raw_offset_ms); + + // Compute DST-on and DST-off transitions in UTC for this year + int64_t dst_start = compute_transition_utc_ms(year, + rule.start_month, + rule.start_day, + rule.start_dow, + rule.start_time, + rule.start_time_mode, + rule.start_mode, + raw_offset_ms, + rule.dst_savings, + true); + + int64_t dst_end = compute_transition_utc_ms(year, + rule.end_month, + rule.end_day, + rule.end_dow, + rule.end_time, + rule.end_time_mode, + rule.end_mode, + raw_offset_ms, + rule.dst_savings, + false); + + bool in_dst; + if (dst_start < dst_end) { + // Northern Hemisphere: DST is [start, end) + in_dst = (utc_ms >= dst_start && utc_ms < dst_end); + } else { + // Southern Hemisphere: DST is [start, year_end) ∪ [year_start, end) + in_dst = (utc_ms >= dst_start || utc_ms < dst_end); + } + + return in_dst ? raw_offset_ms + rule.dst_savings : raw_offset_ms; +} + +/** + * @brief Get the offset for a UTC time using the transition table + DST rule fallback. * - * @return the offset. + * For timestamps within the transition table range, uses binary search. + * For timestamps beyond the table, uses DST rule computation. + * For timestamps before the first recorded transition, falls back to the + * historical initial offset to match java.util.TimeZone behavior. */ __device__ static int32_t get_transition_index(int64_t const* begin, int64_t const* end, int64_t time_ms, int32_t const* offset_begin, int32_t const* offset_end, - int32_t raw_offset) + int32_t initial_offset, + int32_t raw_offset, + spark_rapids_jni::dst_rule const& rule) { if (begin == end) { - // fixed offset timezone, no transitions - return raw_offset; + // No transition table. Use DST rule if available, else fixed offset. + return compute_dst_offset(time_ms, raw_offset, rule); } auto const iter = thrust::upper_bound(thrust::seq, begin, end, time_ms); if (iter == end) { - // after the transition table, returns the raw offset - return raw_offset; + // Beyond the transition table -- use DST rule for future dates + return compute_dst_offset(time_ms, raw_offset, rule); } int32_t index = static_cast(cuda::std::distance(begin, iter)); - if (*iter == time_ms) { - // find exact match, return the offset at that index - return offset_begin[index]; - } + if (*iter == time_ms) { return offset_begin[index]; } if (index == 0) { - // prior to the transition table, returns the raw offset - return raw_offset; + // Before the first recorded transition, java.util.TimeZone uses the + // historical offset in effect before that transition, not the future rule. + return initial_offset; } - // return the offset at the previous index return offset_begin[index - 1]; } /** - * @brief Find the relative offset when moving between timezones at a particular point in time. - * This is for ORC timezone support. + * @brief Get the fixed offset for a timezone with no transitions and no DST. + * Returns true if the timezone is a simple fixed-offset timezone (constant offset). + */ +__device__ static bool is_fixed_offset_tz(int64_t const* trans_begin, + int64_t const* trans_end, + spark_rapids_jni::dst_rule const& dst) +{ + return (trans_begin == trans_end) && !dst.has_dst; +} + +/** + * @brief Convert a timestamp between ORC writer and reader timezones. * - * This function implements `org.apache.orc.impl.SerializationUtils.convertBetweenTimezones` - * Refer to link: https://github.com/apache/orc/blob/rel/release-1.9.1/java/core/src/ - * java/org/apache/orc/impl/SerializationUtils.java#L1440 + * Implements org.apache.orc.impl.SerializationUtils.convertBetweenTimezones. * - * If the input `trans_begin` == `trans_begin` == nullptr, it means the timezone is fixed offset, - * then use the raw_offset directly. + * Optimized for common cases: + * - Fixed-offset reader (e.g. UTC): skip all reader lookups, use constant offset. + * - Fixed-offset writer: skip writer lookups. */ __device__ static cudf::timestamp_us convert_timestamp_between_timezones( cudf::timestamp_us ts, + int64_t base_offset_us, int64_t const* writer_trans_begin, int64_t const* writer_trans_end, int32_t const* writer_offsets_begin, int32_t const* writer_offsets_end, + int32_t writer_initial_offset, int32_t writer_raw_offset, + spark_rapids_jni::dst_rule const& writer_dst, int64_t const* reader_trans_begin, int64_t const* reader_trans_end, int32_t const* reader_offsets_begin, int32_t const* reader_offsets_end, - int32_t reader_raw_offset) + int32_t reader_initial_offset, + int32_t reader_raw_offset, + spark_rapids_jni::dst_rule const& reader_dst) { constexpr int64_t MICROS_PER_MILLI = 1000L; - int64_t const epoch_millis = static_cast( - cuda::std::chrono::duration_cast(ts.time_since_epoch()).count()); - - int32_t writer_offset_millis = get_transition_index(writer_trans_begin, - writer_trans_end, - epoch_millis, - writer_offsets_begin, - writer_offsets_end, - writer_raw_offset); - - int32_t reader_offset_millis = get_transition_index(reader_trans_begin, - reader_trans_end, - epoch_millis, - reader_offsets_begin, - reader_offsets_end, - reader_raw_offset); - - int64_t adjusted_milliseconds = epoch_millis + (writer_offset_millis - reader_offset_millis); - int const reader_adjusted_offset = get_transition_index(reader_trans_begin, - reader_trans_end, - adjusted_milliseconds, - reader_offsets_begin, - reader_offsets_end, - reader_raw_offset); + int64_t const adjusted_us = + static_cast( + cuda::std::chrono::duration_cast(ts.time_since_epoch()).count()) - + base_offset_us; + + // Floor-divide to get epoch millis (handles negative timestamps correctly) + int64_t const epoch_millis = + (adjusted_us >= 0) ? adjusted_us / MICROS_PER_MILLI : (adjusted_us - 999) / MICROS_PER_MILLI; + + bool const writer_fixed = is_fixed_offset_tz(writer_trans_begin, writer_trans_end, writer_dst); + bool const reader_fixed = is_fixed_offset_tz(reader_trans_begin, reader_trans_end, reader_dst); + + int32_t writer_offset_millis = writer_fixed ? writer_raw_offset + : get_transition_index(writer_trans_begin, + writer_trans_end, + epoch_millis, + writer_offsets_begin, + writer_offsets_end, + writer_initial_offset, + writer_raw_offset, + writer_dst); + + int32_t reader_offset_millis = reader_fixed ? reader_raw_offset + : get_transition_index(reader_trans_begin, + reader_trans_end, + epoch_millis, + reader_offsets_begin, + reader_offsets_end, + reader_initial_offset, + reader_raw_offset, + reader_dst); + + int64_t adjusted_milliseconds = epoch_millis + (writer_offset_millis - reader_offset_millis); + + int32_t reader_adjusted_offset = reader_fixed ? reader_raw_offset + : get_transition_index(reader_trans_begin, + reader_trans_end, + adjusted_milliseconds, + reader_offsets_begin, + reader_offsets_end, + reader_initial_offset, + reader_raw_offset, + reader_dst); int32_t final_offset_millis = writer_offset_millis - reader_adjusted_offset; - int64_t const epoch_us = static_cast( - cuda::std::chrono::duration_cast(ts.time_since_epoch()).count()); - int64_t final_result = epoch_us + static_cast(final_offset_millis) * MICROS_PER_MILLI; + int64_t final_result = adjusted_us + static_cast(final_offset_millis) * MICROS_PER_MILLI; return cudf::timestamp_us{cudf::duration_us{final_result}}; } -struct convert_timezones_functor { - // writer timezone info - int64_t const* writer_trans_begin; - int64_t const* writer_trans_end; - int32_t const* writer_offsets_begin; - int32_t const* writer_offsets_end; - int32_t writer_raw_offset; - - // reader timezone info - int64_t const* reader_trans_begin; - int64_t const* reader_trans_end; - int32_t const* reader_offsets_begin; - int32_t const* reader_offsets_end; - int32_t reader_raw_offset; - - __device__ cudf::timestamp_us operator()(cudf::timestamp_us const& timestamp) const - { - return convert_timestamp_between_timezones(timestamp, - writer_trans_begin, - writer_trans_end, - writer_offsets_begin, - writer_offsets_end, - writer_raw_offset, - reader_trans_begin, - reader_trans_end, - reader_offsets_begin, - reader_offsets_end, - reader_raw_offset); +// Max transition entries per timezone that can be loaded into shared memory. +// Each entry = 8 bytes (transition) + 4 bytes (offset) = 12 bytes. +// With writer + reader at max: 2 * 512 * 12 = 12KB, well within 48KB limit. +constexpr int32_t MAX_SMEM_TRANSITIONS = 512; +constexpr int32_t CONVERT_TZ_BLOCK_SIZE = 256; + +__global__ void convert_timezones_kernel(cudf::timestamp_us const* __restrict__ input, + cudf::bitmask_type const* __restrict__ null_mask, + cudf::timestamp_us* __restrict__ output, + cudf::size_type num_rows, + int64_t base_offset_us, + int64_t const* __restrict__ g_writer_trans, + int32_t const* __restrict__ g_writer_offsets, + int32_t writer_trans_count, + int32_t writer_initial_offset, + int32_t writer_raw_offset, + spark_rapids_jni::dst_rule writer_dst, + int64_t const* __restrict__ g_reader_trans, + int32_t const* __restrict__ g_reader_offsets, + int32_t reader_trans_count, + int32_t reader_initial_offset, + int32_t reader_raw_offset, + spark_rapids_jni::dst_rule reader_dst) +{ + // Shared memory layout: writer transitions, writer offsets, reader transitions, reader offsets + extern __shared__ char smem[]; + + bool const writer_fits = writer_trans_count <= MAX_SMEM_TRANSITIONS; + bool const reader_fits = reader_trans_count <= MAX_SMEM_TRANSITIONS; + + int64_t* s_writer_trans = nullptr; + int32_t* s_writer_offsets = nullptr; + int64_t* s_reader_trans = nullptr; + int32_t* s_reader_offsets = nullptr; + + auto align_up = [](char* p, size_t alignment) -> char* { + auto addr = reinterpret_cast(p); + return reinterpret_cast((addr + alignment - 1) & ~(alignment - 1)); + }; + + char* ptr = smem; + if (writer_fits && writer_trans_count > 0) { + s_writer_trans = reinterpret_cast(ptr); + ptr += writer_trans_count * sizeof(int64_t); + s_writer_offsets = reinterpret_cast(ptr); + ptr += writer_trans_count * sizeof(int32_t); } -}; + if (reader_fits && reader_trans_count > 0) { + ptr = align_up(ptr, alignof(int64_t)); + s_reader_trans = reinterpret_cast(ptr); + ptr += reader_trans_count * sizeof(int64_t); + s_reader_offsets = reinterpret_cast(ptr); + } + + // Cooperatively load transition tables into shared memory + for (int32_t i = threadIdx.x; i < writer_trans_count && writer_fits; i += blockDim.x) { + s_writer_trans[i] = g_writer_trans[i]; + s_writer_offsets[i] = g_writer_offsets[i]; + } + for (int32_t i = threadIdx.x; i < reader_trans_count && reader_fits; i += blockDim.x) { + s_reader_trans[i] = g_reader_trans[i]; + s_reader_offsets[i] = g_reader_offsets[i]; + } + __syncthreads(); + + int64_t const* wt_begin = writer_fits ? s_writer_trans : g_writer_trans; + int64_t const* wt_end = wt_begin ? wt_begin + writer_trans_count : nullptr; + int32_t const* wo_begin = writer_fits ? s_writer_offsets : g_writer_offsets; + int32_t const* wo_end = wo_begin ? wo_begin + writer_trans_count : nullptr; + + int64_t const* rt_begin = reader_fits ? s_reader_trans : g_reader_trans; + int64_t const* rt_end = rt_begin ? rt_begin + reader_trans_count : nullptr; + int32_t const* ro_begin = reader_fits ? s_reader_offsets : g_reader_offsets; + int32_t const* ro_end = ro_begin ? ro_begin + reader_trans_count : nullptr; + + // Handle null transition tables (fixed-offset timezones use nullptr) + if (!g_writer_trans) { + wt_begin = wt_end = nullptr; + wo_begin = wo_end = nullptr; + } + if (!g_reader_trans) { + rt_begin = rt_end = nullptr; + ro_begin = ro_end = nullptr; + } + + cudf::size_type idx = blockIdx.x * blockDim.x + threadIdx.x; + if (idx < num_rows) { + if (null_mask && !cudf::bit_is_set(null_mask, idx)) { return; } + output[idx] = convert_timestamp_between_timezones(input[idx], + base_offset_us, + wt_begin, + wt_end, + wo_begin, + wo_end, + writer_initial_offset, + writer_raw_offset, + writer_dst, + rt_begin, + rt_end, + ro_begin, + ro_end, + reader_initial_offset, + reader_raw_offset, + reader_dst); + } +} std::unique_ptr convert_timezones(cudf::column_view const& input, + int64_t base_offset_us, cudf::table_view const* writer_tz_info_table, + cudf::size_type writer_initial_offset, cudf::size_type writer_raw_offset, + spark_rapids_jni::dst_rule writer_dst, cudf::table_view const* reader_tz_info_table, + cudf::size_type reader_initial_offset, cudf::size_type reader_raw_offset, + spark_rapids_jni::dst_rule reader_dst, rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { - // Input is from Spark, so it should be TIMESTAMP_MICROSECONDS type CUDF_EXPECTS(input.type().id() == cudf::type_id::TIMESTAMP_MICROSECONDS, "Input column must be of type TIMESTAMP_MICROSECONDS"); auto results = cudf::make_timestamp_column(input.type(), @@ -395,92 +733,50 @@ std::unique_ptr convert_timezones(cudf::column_view const& input, stream, mr); - int64_t const* writer_trans_begin = [&]() { - if (writer_tz_info_table != nullptr) { - return writer_tz_info_table->column(0).begin(); - } else { - // fixed transition, has no transitions - return static_cast(0); - } - }(); - - int64_t const* writer_trans_end = [&]() { - if (writer_tz_info_table != nullptr) { - return writer_tz_info_table->column(0).end(); - } else { - // fixed transition, has no transitions - return static_cast(0); - } - }(); - - int32_t const* writer_offsets_begin = [&]() { - if (writer_tz_info_table != nullptr) { - return writer_tz_info_table->column(1).begin(); - } else { - // fixed transition, has no transitions - return static_cast(0); - } - }(); - - int32_t const* writer_offsets_end = [&]() { - if (writer_tz_info_table != nullptr) { - return writer_tz_info_table->column(1).end(); - } else { - // fixed transition, has no transitions - return static_cast(0); - } - }(); + if (input.size() == 0) { return results; } - int64_t const* reader_trans_begin = [&]() { - if (reader_tz_info_table != nullptr) { - return reader_tz_info_table->column(0).begin(); - } else { - // fixed transition, has no transitions - return static_cast(0); - } - }(); + int64_t const* writer_trans_ptr = + writer_tz_info_table ? writer_tz_info_table->column(0).begin() : nullptr; + int32_t const* writer_offsets_ptr = + writer_tz_info_table ? writer_tz_info_table->column(1).begin() : nullptr; + int32_t writer_trans_count = writer_tz_info_table ? writer_tz_info_table->column(0).size() : 0; - int64_t const* reader_trans_end = [&]() { - if (reader_tz_info_table != nullptr) { - return reader_tz_info_table->column(0).end(); - } else { - // fixed transition, has no transitions - return static_cast(0); - } - }(); + int64_t const* reader_trans_ptr = + reader_tz_info_table ? reader_tz_info_table->column(0).begin() : nullptr; + int32_t const* reader_offsets_ptr = + reader_tz_info_table ? reader_tz_info_table->column(1).begin() : nullptr; + int32_t reader_trans_count = reader_tz_info_table ? reader_tz_info_table->column(0).size() : 0; - int32_t const* reader_offsets_begin = [&]() { - if (reader_tz_info_table != nullptr) { - return reader_tz_info_table->column(1).begin(); - } else { - // fixed transition, has no transitions - return static_cast(0); - } - }(); + size_t smem_bytes = 0; + if (writer_trans_count > 0 && writer_trans_count <= MAX_SMEM_TRANSITIONS) { + smem_bytes += writer_trans_count * (sizeof(int64_t) + sizeof(int32_t)); + } + if (reader_trans_count > 0 && reader_trans_count <= MAX_SMEM_TRANSITIONS) { + // Alignment padding between writer offsets (int32_t) and reader transitions (int64_t) + smem_bytes = (smem_bytes + alignof(int64_t) - 1) & ~(alignof(int64_t) - 1); + smem_bytes += reader_trans_count * (sizeof(int64_t) + sizeof(int32_t)); + } - int32_t const* reader_offsets_end = [&]() { - if (reader_tz_info_table != nullptr) { - return reader_tz_info_table->column(1).end(); - } else { - // fixed transition, has no transitions - return static_cast(0); - } - }(); - - thrust::transform(rmm::exec_policy_nosync(stream), - input.begin(), - input.end(), - results->mutable_view().begin(), - convert_timezones_functor{writer_trans_begin, - writer_trans_end, - writer_offsets_begin, - writer_offsets_end, - writer_raw_offset, - reader_trans_begin, - reader_trans_end, - reader_offsets_begin, - reader_offsets_end, - reader_raw_offset}); + int32_t num_blocks = (input.size() + CONVERT_TZ_BLOCK_SIZE - 1) / CONVERT_TZ_BLOCK_SIZE; + + convert_timezones_kernel<<>>( + input.begin(), + input.null_mask(), + results->mutable_view().begin(), + input.size(), + base_offset_us, + writer_trans_ptr, + writer_offsets_ptr, + writer_trans_count, + static_cast(writer_initial_offset), + writer_raw_offset, + writer_dst, + reader_trans_ptr, + reader_offsets_ptr, + reader_trans_count, + static_cast(reader_initial_offset), + reader_raw_offset, + reader_dst); return results; } @@ -561,18 +857,28 @@ std::unique_ptr convert_timestamp_to_utc(column_view const& input_second std::unique_ptr convert_orc_writer_reader_timezones( cudf::column_view const& input, + int64_t base_offset_us, cudf::table_view const* writer_tz_info_table, + cudf::size_type writer_initial_offset, cudf::size_type writer_raw_offset, + dst_rule writer_dst, cudf::table_view const* reader_tz_info_table, + cudf::size_type reader_initial_offset, cudf::size_type reader_raw_offset, + dst_rule reader_dst, rmm::cuda_stream_view stream, rmm::device_async_resource_ref mr) { return convert_timezones(input, + base_offset_us, writer_tz_info_table, + writer_initial_offset, writer_raw_offset, + writer_dst, reader_tz_info_table, + reader_initial_offset, reader_raw_offset, + reader_dst, stream, mr); } diff --git a/src/main/cpp/src/timezones.hpp b/src/main/cpp/src/timezones.hpp index 67f2862920..5ac1057a22 100644 --- a/src/main/cpp/src/timezones.hpp +++ b/src/main/cpp/src/timezones.hpp @@ -72,7 +72,7 @@ std::unique_ptr convert_utc_timestamp_to_timezone( /** * @brief Convert timestamps in multiple timezones to UTC. * This is used for casting string(with timezone) to timestamp. - * Note: The input timestamps are splited into seconds and microseconds columns to handle special + * Note: The input timestamps are split into seconds and microseconds columns to handle special * cases: before conversion the timestamp is overflow, but after conversion it is valid. * * @param input_seconds the seconds column for the input timestamps @@ -100,29 +100,71 @@ std::unique_ptr convert_timestamp_to_utc( rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); +/** + * @brief DST rule parameters extracted from java.util.SimpleTimeZone. + * + * Used by the GPU kernel to compute timezone offsets for timestamps beyond + * the historical transition table, implementing SimpleTimeZone.getOffset() on GPU. + * + * Mode values for start_mode/end_mode: + * 0 = DOM_MODE: exact day of month + * 1 = DOW_IN_MONTH_MODE: nth dayOfWeek in month + * 2 = DOW_GE_DOM_MODE: first dayOfWeek on or after day + * 3 = DOW_LE_DOM_MODE: last dayOfWeek on or before day + * + * Time mode values for start_time_mode/end_time_mode: + * 0 = WALL_TIME, 1 = STANDARD_TIME, 2 = UTC_TIME + */ +struct dst_rule { + bool has_dst; // false means no DST, just use raw_offset + int32_t dst_savings; // in milliseconds (typically 3600000) + int32_t start_month; // 0-based (Jan=0..Dec=11) + int32_t start_day; // day-of-month or occurrence, depends on start_mode + int32_t start_dow; // day-of-week 1=Sun..7=Sat, 0 for DOM_MODE + int32_t start_time; // ms within day + int32_t start_time_mode; + int32_t start_mode; // 0=DOM, 1=DOW_IN_MONTH, 2=DOW_GE_DOM, 3=DOW_LE_DOM + int32_t end_month; + int32_t end_day; + int32_t end_dow; + int32_t end_time; + int32_t end_time_mode; + int32_t end_mode; +}; + /** * @brief Convert between ORC writer timezone and reader timezone. * - * If `writer_tz_info_table` is nullptr, it means the writer timezone is fixed offset. - * If `reader_tz_info_table` is nullptr, it means the reader timezone is fixed offset. + * Uses historical transition table for dates within the table range, and + * DST rules (from SimpleTimeZone) for dates beyond the table. * * @param input The input timestamp column in microseconds. - * @param writer_tz_info_table The writer timezone table which contains a transition column and a - * timezone index column both in milliseconds. - * @param writer_raw_offset the raw offset in seconds. - * @param reader_tz_info_table The reader timezone table which contains a transition column and a - * timezone index column both in milliseconds. - * @param reader_raw_offset the raw offset in seconds. - * @param stream CUDA stream used for device memory operations and kernel launches. - * @param mr Device memory resource used to allocate the returned timestamp column's memory - * @return a column of timestamps rebased between writer and reader timezones. + * @param base_offset_us Fixed microsecond offset to apply before timezone conversion. + * Fuses ORC's base-timestamp adjustment (writer TZ offset at 2015-01-01) into + * the kernel, eliminating a separate pass. Pass 0 for no adjustment. + * @param writer_tz_info_table transition/offset table, nullptr for fixed-offset TZ. + * @param writer_initial_offset the historical offset before the first transition. + * @param writer_raw_offset the standard/raw offset in milliseconds used for DST fallback. + * @param writer_dst DST rule for the writer timezone. + * @param reader_tz_info_table transition/offset table, nullptr for fixed-offset TZ. + * @param reader_initial_offset the historical offset before the first transition. + * @param reader_raw_offset the standard/raw offset in milliseconds used for DST fallback. + * @param reader_dst DST rule for the reader timezone. + * @param stream CUDA stream. + * @param mr Device memory resource. + * @return timestamps rebased between writer and reader timezones. */ std::unique_ptr convert_orc_writer_reader_timezones( cudf::column_view const& input, + int64_t base_offset_us, cudf::table_view const* writer_tz_info_table, + cudf::size_type writer_initial_offset, cudf::size_type writer_raw_offset, + dst_rule writer_dst, cudf::table_view const* reader_tz_info_table, + cudf::size_type reader_initial_offset, cudf::size_type reader_raw_offset, + dst_rule reader_dst, rmm::cuda_stream_view stream = cudf::get_default_stream(), rmm::device_async_resource_ref mr = cudf::get_current_device_resource_ref()); diff --git a/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java b/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java index 19db213b08..b3cc97c656 100644 --- a/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java +++ b/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java @@ -24,13 +24,13 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.time.DateTimeException; import java.time.DayOfWeek; import java.time.Instant; import java.time.ZoneId; import java.time.zone.ZoneOffsetTransition; import java.time.zone.ZoneOffsetTransitionRule; import java.time.zone.ZoneRules; -import java.time.zone.ZoneRulesException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -42,7 +42,7 @@ /** * Gpu timezone utility. - * + *

* Provides the following APIs * - Timezone rebasing APIs: `fromTimestampToUtcTimestamp`, etc. * - Utilities for casting string with timezone to timestamp APIs @@ -66,7 +66,7 @@ public class GpuTimeZoneDB { * If a timezone has DST, then the list has 12 integers, which contains 2 * rules(start rule and end rule) * The integers in a list are: - * + *

* index 0: month:int, // from 1 (January) to 12 (December) * index 1: dayOfMonth: int, // from -28 to 31 excluding 0 * index 2: dayOfWeek: int, // from 0 (Monday) to 6 (Sunday), -1 means ignore @@ -186,7 +186,7 @@ public static boolean isSupportedTimeZone(String zoneId) { // check that zoneID is valid and supported by Java getZoneId(zoneId); return true; - } catch (ZoneRulesException e) { + } catch (DateTimeException e) { return false; } } @@ -289,7 +289,7 @@ private static synchronized void loadData() { Collections.sort(sortedTimeZones); List> masterTransitions = new ArrayList<>(); - List> masterDsts = new ArrayList<>(); + List> masterDSTs = new ArrayList<>(); zoneIdToTable = new HashMap<>(); for (String nonNormalizedTz : sortedTimeZones) { @@ -361,7 +361,7 @@ private static synchronized void loadData() { }); } masterTransitions.add(data); - masterDsts.add(dstData); + masterDSTs.add(dstData); // add index for normalized timezone zoneIdToTable.put(normalizedTz, idx); } // end of: if (!zoneIdToTable.containsKey(normalizedTz)) { @@ -381,7 +381,7 @@ private static synchronized void loadData() { HostColumnVector.DataType transitionType = new HostColumnVector.ListType(false, childType); fixedTransitions = HostColumnVector.fromLists(transitionType, masterTransitions.toArray(new List[0])); - dstRules = HostColumnVector.fromLists(getDstDataType(), masterDsts.toArray(new List[0])); + dstRules = HostColumnVector.fromLists(getDstDataType(), masterDSTs.toArray(new List[0])); tzNameToIndexMap = getTzNameToIndexMap(sortedTimeZones, zoneIdToTable); } catch (Exception e) { throw new IllegalStateException("load timezone DB cache failed!", e); @@ -528,7 +528,6 @@ private static ColumnVector getOffsetsForUtilTZ(OrcTimezoneInfo info) { private static Table getTableForUtilTZ(OrcTimezoneInfo info) { if (info.transitions == null) { - // fixed offset timezone return null; } try (ColumnVector trans = getTransitionsForUtilTZ(info); @@ -548,54 +547,115 @@ static List getOrcSupportedTimezones() { } /** - * Does the given timezone have Daylight Saving Time(DST) rules. + * Pre-built device-side timezone context for ORC timezone conversion. + * Holds the writer and reader transition tables on GPU so they can be + * reused across multiple timestamp columns in the same table. */ - private static boolean hasDaylightSavingTime(String timezoneId) { - ZoneId zoneId = ZoneId.of(timezoneId, ZoneId.SHORT_IDS); - return !zoneId.getRules().getTransitionRules().isEmpty(); + public static class OrcTimezoneContext implements AutoCloseable { + private final Table writerTzInfoTable; + private final Table readerTzInfoTable; + private final int writerInitialOffset; + private final int writerRawOffset; + private final int[] writerDst; + private final int readerInitialOffset; + private final int readerRawOffset; + private final int[] readerDst; + + private OrcTimezoneContext(Table writerTzInfoTable, Table readerTzInfoTable, + OrcTimezoneInfo writerTzInfo, OrcTimezoneInfo readerTzInfo) { + this.writerTzInfoTable = writerTzInfoTable; + this.readerTzInfoTable = readerTzInfoTable; + this.writerInitialOffset = writerTzInfo.initialOffset; + this.writerRawOffset = writerTzInfo.rawOffset; + this.writerDst = dstRuleToArray(writerTzInfo.dstRule); + this.readerInitialOffset = readerTzInfo.initialOffset; + this.readerRawOffset = readerTzInfo.rawOffset; + this.readerDst = dstRuleToArray(readerTzInfo.dstRule); + } + + @Override + public void close() { + if (writerTzInfoTable != null) { + writerTzInfoTable.close(); + } + if (readerTzInfoTable != null) { + readerTzInfoTable.close(); + } + } + } + + /** + * Build a reusable ORC timezone context that holds device-side transition + * tables. Call once per (writerTz, readerTz) pair and reuse across columns. + */ + public static OrcTimezoneContext buildOrcTimezoneContext( + String writerTimezone, String readerTimezone) { + OrcTimezoneInfo writerTzInfo = OrcTimezoneInfo.get(writerTimezone); + OrcTimezoneInfo readerTzInfo = OrcTimezoneInfo.get(readerTimezone); + Table writerTable = getTableForUtilTZ(writerTzInfo); + Table readerTable = null; + try { + readerTable = getTableForUtilTZ(readerTzInfo); + return new OrcTimezoneContext(writerTable, readerTable, writerTzInfo, readerTzInfo); + } catch (Exception e) { + if (writerTable != null) writerTable.close(); + if (readerTable != null) readerTable.close(); + throw new IllegalStateException("build ORC timezone context failed!", e); + } + } + + /** + * Convert timestamps using a pre-built ORC timezone context, avoiding + * repeated host-to-device copies of transition tables. + */ + public static ColumnVector convertOrcTimezones( + ColumnView input, long baseOffsetUs, OrcTimezoneContext ctx) { + return new ColumnVector(convertOrcTimezones( + input.getNativeView(), + baseOffsetUs, + ctx.writerTzInfoTable != null ? ctx.writerTzInfoTable.getNativeView() : 0L, + ctx.writerInitialOffset, + ctx.writerRawOffset, + ctx.writerDst, + ctx.readerTzInfoTable != null ? ctx.readerTzInfoTable.getNativeView() : 0L, + ctx.readerInitialOffset, + ctx.readerRawOffset, + ctx.readerDst)); } /** * Convert timestamps between writer/reader timezones for ORC reading. - * Similar to `org.apache.orc.impl.SerializationUtils.convertBetweenTimezones`. - * `SerializationUtils.convertBetweenTimezones` gets offset between timezones. - * This function does the same thing and then apply the offset to get the - * final timestamps. - * For more details, refer to: - * link - * - * @param input input timestamp column in microseconds. - * @param writerTimezone writer timezone, it's from ORC stripe metadata. - * @param readerTimezone reader timezone, it's from current JVM default - * timezone. - * @return timestamp column in microseconds after converting between timezones + * Convenience overload that builds timezone context internally. + * For multiple columns, prefer {@link #buildOrcTimezoneContext} + + * {@link #convertOrcTimezones(ColumnVector, long, OrcTimezoneContext)}. */ public static ColumnVector convertOrcTimezones( ColumnVector input, + long baseOffsetUs, String writerTimezone, String readerTimezone) { - // Does not support DST timezone now, just throw exception. - if (hasDaylightSavingTime(writerTimezone) || - hasDaylightSavingTime(readerTimezone)) { - throw new UnsupportedOperationException("Daylight Saving Time is not supported now."); + try (OrcTimezoneContext ctx = buildOrcTimezoneContext(writerTimezone, readerTimezone)) { + return convertOrcTimezones(input, baseOffsetUs, ctx); } + } - // get timezone info from `java.util.TimeZone` - OrcTimezoneInfo writerTzInfo = OrcTimezoneInfo.get(writerTimezone); - OrcTimezoneInfo readerTzInfo = OrcTimezoneInfo.get(readerTimezone); - try (Table writerTzInfoTable = getTableForUtilTZ(writerTzInfo); - Table readerTzInfoTable = getTableForUtilTZ(readerTzInfo)) { - - // convert between timezones - return new ColumnVector(convertOrcTimezones( - input.getNativeView(), - writerTzInfoTable != null ? writerTzInfoTable.getNativeView() : 0L, - writerTzInfo.rawOffset, - readerTzInfoTable != null ? readerTzInfoTable.getNativeView() : 0L, - readerTzInfo.rawOffset)); - } catch (Exception e) { - throw new IllegalStateException("convert between timezones failed!", e); + /** + * Convert a DstRule to a 13-element int array for JNI, or null if no DST. + * Order: dstSavings, startMonth, startDay, startDayOfWeek, startTime, + * startTimeMode, startMode, endMonth, endDay, endDayOfWeek, + * endTime, endTimeMode, endMode + */ + private static int[] dstRuleToArray(OrcTimezoneInfo.DstRule rule) { + if (rule == null) { + return null; } + return new int[]{ + rule.dstSavings, + rule.startMonth, rule.startDay, rule.startDayOfWeek, + rule.startTime, rule.startTimeMode, rule.startMode, + rule.endMonth, rule.endDay, rule.endDayOfWeek, + rule.endTime, rule.endTimeMode, rule.endMode + }; } private static native long convertTimestampColumnToUTC(long input, long timezoneInfo, int tzIndex); @@ -608,8 +668,13 @@ private static native long convertTimestampColumnToUTCWithTzCv( private static native long convertOrcTimezones( long input, + long baseOffsetUs, long writerTzInfoTable, + int writerTzInitialOffset, int writerTzRawOffset, + int[] writerDstRule, long readerTzInfoTable, - int readerTzRawOffset); + int readerTzInitialOffset, + int readerTzRawOffset, + int[] readerDstRule); } diff --git a/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java b/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java index 29b2631eb1..6898972114 100644 --- a/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java +++ b/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java @@ -1,32 +1,42 @@ package com.nvidia.spark.rapids.jni; -import java.io.IOException; -import java.io.RandomAccessFile; -import java.net.URL; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.MappedByteBuffer; -import java.nio.channels.FileChannel; +import java.time.DateTimeException; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.zone.ZoneOffsetTransition; +import java.time.zone.ZoneOffsetTransitionRule; +import java.time.zone.ZoneRules; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.TimeZone; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; /** - * Used to hold timezone info read from `java.util.TimeZone` - * This class is used for ORC timezone conversion. - * For the other timezone conversions, it uses `java.time.ZoneId` APIs. - * The information is generated from OpenJDK 8. So some timezones in newer JDKs are missing. - * The reason why we do not read timezone info directly from `java.util.TimeZone`: - * `sun.util.calendar.ZoneInfo` is not public API, on some JDK distributions (like Oracle JDK), - * it's not accessible, E.g.: report error: package sun.util.calendar is not visible + * Holds ORC timezone metadata generated at runtime from public java.time/java.util APIs. + * Historical transitions come from ZoneRules, while offsets before the first transition and + * future recurring DST behavior are validated against java.util.TimeZone so ORC rebasing matches + * SerializationUtils.convertBetweenTimezones semantics without relying on non-public ZoneInfo APIs. */ class OrcTimezoneInfo { - public OrcTimezoneInfo(int rawOffset, long[] transitions, int[] offsets) { + public OrcTimezoneInfo( + int initialOffset, + int rawOffset, + long[] transitions, + int[] offsets, + DstRule dstRule) { + this.initialOffset = initialOffset; this.rawOffset = rawOffset; this.transitions = transitions; this.offsets = offsets; + this.dstRule = dstRule; } // in milliseconds + int initialOffset; + + // in milliseconds. This is the standard/raw offset used for DST rule math. int rawOffset; // in milliseconds @@ -35,189 +45,587 @@ public OrcTimezoneInfo(int rawOffset, long[] transitions, int[] offsets) { // in milliseconds int[] offsets; - @Override - public String toString() { - return "OrcTimezoneInfo{" + - "rawOffset=" + rawOffset + - ", transitions=" + Arrays.toString(transitions) + - ", offsets=" + Arrays.toString(offsets) + - '}'; + /** + * DST rule extracted from java.util.SimpleTimeZone for computing offsets + * beyond the historical transition table. Null if the timezone has no DST. + * + * The CUDA kernel uses this to implement SimpleTimeZone.getOffset() on GPU, + * eliminating the need for pre-generated transition files for future dates. + */ + DstRule dstRule; + + /** + * Holds the DST rule parameters needed by the GPU kernel. + * These correspond to the fields of java.util.SimpleTimeZone. + * + * The "mode" fields encode how startDay/endDay are interpreted: + * 0 = DOM_MODE: exact day of month + * 1 = DOW_IN_MONTH_MODE: nth dayOfWeek in month (negative = from end) + * 2 = DOW_GE_DOM_MODE: first dayOfWeek on or after day + * 3 = DOW_LE_DOM_MODE: last dayOfWeek on or before day + */ + static class DstRule { + int dstSavings; // DST offset in milliseconds (typically 3600000) + int startMonth; // 0-based (Calendar.JANUARY=0 .. Calendar.DECEMBER=11) + int startDay; // day-of-month or occurrence count depending on startMode + int startDayOfWeek; // Calendar day-of-week (1=Sun, ..., 7=Sat), 0 if DOM_MODE + int startTime; // milliseconds within day + int startTimeMode; // 0=WALL, 1=STANDARD, 2=UTC + int startMode; // 0=DOM, 1=DOW_IN_MONTH, 2=DOW_GE_DOM, 3=DOW_LE_DOM + int endMonth; + int endDay; + int endDayOfWeek; + int endTime; + int endTimeMode; + int endMode; + } + + private static final int[] DST_RULE_VALIDATION_YEARS = {2400, 9997}; + private static final long MIN_SUPPORTED_ORC_UTC_MILLIS = utcMillisForDate(1, 0, 1); + private static final long HISTORICAL_TRANSITION_SCAN_STEP_MILLIS = 24L * 3600_000L; + + /** + * Extract DST rule by probing getOffset() or from ZoneRules transition rules. + * Returns null if the timezone has no DST. + */ + static DstRule extractDstRule(String timezoneId, TimeZone tz) { + if (!tz.useDaylightTime()) { + return null; + } + DstRule dstRule = extractDstRuleByProbing(tz); + if (dstRule != null) { + return dstRule; + } + + dstRule = extractDstRuleFromZoneRules(timezoneId, tz); + if (dstRule != null) { + return dstRule; + } + throw new IllegalStateException("Failed to extract ORC DST rule for timezone: " + timezoneId); } - // The following is Static fields and methods. - // The `orc_timezone_info.data` file is generated from `sun.util.calendar.ZoneInfo` on OpenJDK 8 - // It first reads `transitions` and `offsets` fields from `ZoneInfo` via reflection. - // Then calculate the actual transition and offset values via: - // - actual transition = transition >> 12 - // - actual offset = offsets[transition & 0x0FL] - // For more details, please refer to `sun.util.calendar.ZoneInfo` source code. + private static DstRule extractDstRuleFromZoneRules(String timezoneId, TimeZone tz) { + ZoneRules rules = GpuTimeZoneDB.getZoneId(timezoneId).getRules(); + List transitionRules = rules.getTransitionRules(); + if (transitionRules.isEmpty()) { + return null; + } + if (transitionRules.size() != 2) { + throw new IllegalStateException("Unsupported ORC DST rule count for timezone: " + timezoneId); + } + + ZoneOffsetTransitionRule startTransitionRule = null; + ZoneOffsetTransitionRule endTransitionRule = null; + for (ZoneOffsetTransitionRule transitionRule : transitionRules) { + int deltaMillis = (transitionRule.getOffsetAfter().getTotalSeconds() - + transitionRule.getOffsetBefore().getTotalSeconds()) * 1000; + if (deltaMillis > 0) { + startTransitionRule = transitionRule; + } else if (deltaMillis < 0) { + endTransitionRule = transitionRule; + } else { + throw new IllegalStateException("Unsupported zero-delta ORC DST rule for timezone: " + + timezoneId); + } + } + if (startTransitionRule == null || endTransitionRule == null) { + throw new IllegalStateException("Failed to identify ORC DST start/end rules for timezone: " + + timezoneId); + } - // Refer to `serializeTimezoneInfo` method for how to generate the file. - private static final String ORC_TIMEZONE_FILE = "orc_timezone_info.data"; + int dstSavings = (startTransitionRule.getOffsetAfter().getTotalSeconds() - + startTransitionRule.getOffsetBefore().getTotalSeconds()) * 1000; + int endDeltaMillis = (endTransitionRule.getOffsetBefore().getTotalSeconds() - + endTransitionRule.getOffsetAfter().getTotalSeconds()) * 1000; + if (dstSavings != endDeltaMillis) { + throw new IllegalStateException("Mismatched ORC DST savings for timezone: " + timezoneId); + } - // the mapped memory for the file - private static MappedByteBuffer serializedBuf = null; + DstRule rule = new DstRule(); + rule.dstSavings = dstSavings; + fillDstRuleFromTransitionRule(timezoneId, rule, startTransitionRule, true); + fillDstRuleFromTransitionRule(timezoneId, rule, endTransitionRule, false); - static { - readTimezoneInfoFromFile(); + if (!verifyDstRuleAcrossReferenceYears(tz, rule)) { + throw new IllegalStateException("ZoneRules ORC DST rule verification failed for timezone: " + + timezoneId); + } + return rule; } - private static void readTimezoneInfoFromFile() { - URL path = OrcTimezoneInfo.class.getClassLoader().getResource(ORC_TIMEZONE_FILE); - if (path == null) { - throw new RuntimeException("Can not find ORC timezone info file " + ORC_TIMEZONE_FILE); + private static void fillDstRuleFromTransitionRule(String timezoneId, DstRule rule, + ZoneOffsetTransitionRule transitionRule, boolean isStartRule) { + if (transitionRule.getDayOfWeek() == null || + transitionRule.getDayOfMonthIndicator() <= 0) { + throw new IllegalStateException("Unsupported ORC DST transition rule shape for timezone: " + + timezoneId); } - try (RandomAccessFile file = new RandomAccessFile(path.getPath(), "r"); - FileChannel fileChannel = file.getChannel()) { + int month = transitionRule.getMonth().getValue() - 1; + int day = transitionRule.getDayOfMonthIndicator(); + int dayOfWeek = toCalendarDayOfWeek(transitionRule.getDayOfWeek().getValue()); + int time = getTransitionRuleTimeMillis(transitionRule); + int timeMode = getTransitionRuleTimeMode(transitionRule); + int mode = 2; // DOW_GE_DOM_MODE - if (fileChannel.size() > 2 * 1024 * 1024) { // > 2M - throw new RuntimeException("Failed to load ORC timezone info, file is too large > 2M."); - } + if (isStartRule) { + rule.startMonth = month; + rule.startDay = day; + rule.startDayOfWeek = dayOfWeek; + rule.startTime = time; + rule.startTimeMode = timeMode; + rule.startMode = mode; + } else { + rule.endMonth = month; + rule.endDay = day; + rule.endDayOfWeek = dayOfWeek; + rule.endTime = time; + rule.endTimeMode = timeMode; + rule.endMode = mode; + } + } + + private static int getTransitionRuleTimeMillis( + ZoneOffsetTransitionRule transitionRule) { + int secondOfDay = transitionRule.isMidnightEndOfDay() ? + 24 * 3600 : + transitionRule.getLocalTime().toSecondOfDay(); + return secondOfDay * 1000; + } - // Map the file into memory - serializedBuf = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileChannel.size()); - } catch (IOException e) { - throw new RuntimeException("Failed to load ORC timezone info file " + ORC_TIMEZONE_FILE, e); + private static int getTransitionRuleTimeMode(ZoneOffsetTransitionRule transitionRule) { + ZoneOffsetTransitionRule.TimeDefinition timeDef = transitionRule.getTimeDefinition(); + if (ZoneOffsetTransitionRule.TimeDefinition.UTC == timeDef) { + return 2; + } else if (ZoneOffsetTransitionRule.TimeDefinition.STANDARD == timeDef) { + return 1; + } else { + return 0; } } + private static int toCalendarDayOfWeek(int javaTimeDayOfWeek) { + return (javaTimeDayOfWeek % 7) + 1; + } + /** - * Get timezone info for the specified timezone Id - * @param timezoneId timezone Id - * @return timezone info + * Extract DST rule by probing getOffset() at hourly intervals in a reference year. + * This works for any TimeZone implementation (ZoneInfo, SimpleTimeZone, etc.) + * and captures the effective DST rule as the JVM sees it. + * + * We find the exact DST start and end transitions, then encode them in the + * same format that SimpleTimeZone uses internally (month, day, dayOfWeek, time, mode). */ - public static OrcTimezoneInfo get(String timezoneId) { - int index = Arrays.binarySearch(timezoneIds, timezoneId); - if (index < 0) { - throw new IllegalArgumentException("Timezone ID not found: " + timezoneId); + private static DstRule extractDstRuleByProbing(TimeZone tz) { + for (int refYear : DST_RULE_VALIDATION_YEARS) { + DstRule rule = extractDstRuleByProbing(tz, refYear); + if (rule != null && verifyDstRuleAcrossReferenceYears(tz, rule)) { + return rule; + } } + return null; + } - // shallow copy - ByteBuffer buf = serializedBuf.duplicate(); - buf.order(ByteOrder.BIG_ENDIAN); - - int timezoneInfoOffsetInFile = buf.getInt(Integer.BYTES * index); - buf.position(timezoneInfoOffsetInFile); + private static DstRule extractDstRuleByProbing(TimeZone tz, int refYear) { + long janFirst = utcMillisForDate(refYear, 0, 1); + long nextJanFirst = utcMillisForDate(refYear + 1, 0, 1); - int rawOffsets = buf.getInt(); + // Find DST-on and DST-off transitions by scanning hourly + long dstOnTransition = -1; + long dstOffTransition = -1; + int prevOffset = tz.getOffset(janFirst - 1); + long step = 3600_000L; // 1 hour - int numTransitions = buf.getInt(); - long[] transitions = new long[numTransitions]; - for (int i = 0; i < numTransitions; ++i) { - transitions[i] = buf.getLong(); + for (long ms = janFirst; ms < nextJanFirst; ms += step) { + int curOffset = tz.getOffset(ms); + if (curOffset != prevOffset) { + // Found a transition; narrow down to exact millisecond with binary search + long exactMs = binarySearchTransition(tz, ms - step, ms); + if (curOffset > prevOffset) { + dstOnTransition = exactMs; + } else { + dstOffTransition = exactMs; + } + prevOffset = curOffset; + } } - int numOffsets = buf.getInt(); - int[] offsets = new int[numOffsets]; - for (int i = 0; i < numOffsets; ++i) { - offsets[i] = buf.getInt(); + if (dstOnTransition < 0 || dstOffTransition < 0) { + return null; } - return new OrcTimezoneInfo(rawOffsets, transitions, offsets); + DstRule rule = new DstRule(); + rule.dstSavings = tz.getDSTSavings(); + + // Decode the DST-on transition + int[] startFields = decodeTransition(dstOnTransition, tz.getRawOffset()); + rule.startMonth = startFields[0]; + rule.startDay = startFields[1]; + rule.startDayOfWeek = startFields[2]; + rule.startTime = startFields[3]; + rule.startTimeMode = 1; // STANDARD_TIME - decodeTransition converts to standard local time + rule.startMode = startFields[4]; + + // Decode the DST-off transition + int[] endFields = decodeTransition(dstOffTransition, tz.getRawOffset()); + rule.endMonth = endFields[0]; + rule.endDay = endFields[1]; + rule.endDayOfWeek = endFields[2]; + rule.endTime = endFields[3]; + rule.endTimeMode = 1; // STANDARD_TIME + rule.endMode = endFields[4]; + + return rule; } - public static List getAllTimezoneIds() { - return Arrays.asList(timezoneIds); + private static boolean verifyDstRuleAcrossReferenceYears(TimeZone tz, DstRule rule) { + for (int refYear : DST_RULE_VALIDATION_YEARS) { + if (!verifyDstRule(tz, rule, refYear)) { + return false; + } + } + return true; } - private static final String[] timezoneIds = {"ACT", "AET", "AGT", "ART", "AST", "Africa/Abidjan", "Africa/Accra", "Africa/Addis_Ababa", "Africa/Algiers", "Africa/Asmara", "Africa/Asmera", "Africa/Bamako", "Africa/Bangui", "Africa/Banjul", "Africa/Bissau", "Africa/Blantyre", "Africa/Brazzaville", "Africa/Bujumbura", "Africa/Cairo", "Africa/Casablanca", "Africa/Ceuta", "Africa/Conakry", "Africa/Dakar", "Africa/Dar_es_Salaam", "Africa/Djibouti", "Africa/Douala", "Africa/El_Aaiun", "Africa/Freetown", "Africa/Gaborone", "Africa/Harare", "Africa/Johannesburg", "Africa/Juba", "Africa/Kampala", "Africa/Khartoum", "Africa/Kigali", "Africa/Kinshasa", "Africa/Lagos", "Africa/Libreville", "Africa/Lome", "Africa/Luanda", "Africa/Lubumbashi", "Africa/Lusaka", "Africa/Malabo", "Africa/Maputo", "Africa/Maseru", "Africa/Mbabane", "Africa/Mogadishu", "Africa/Monrovia", "Africa/Nairobi", "Africa/Ndjamena", "Africa/Niamey", "Africa/Nouakchott", "Africa/Ouagadougou", "Africa/Porto-Novo", "Africa/Sao_Tome", "Africa/Timbuktu", "Africa/Tripoli", "Africa/Tunis", "Africa/Windhoek", "America/Adak", "America/Anchorage", "America/Anguilla", "America/Antigua", "America/Araguaina", "America/Argentina/Buenos_Aires", "America/Argentina/Catamarca", "America/Argentina/ComodRivadavia", "America/Argentina/Cordoba", "America/Argentina/Jujuy", "America/Argentina/La_Rioja", "America/Argentina/Mendoza", "America/Argentina/Rio_Gallegos", "America/Argentina/Salta", "America/Argentina/San_Juan", "America/Argentina/San_Luis", "America/Argentina/Tucuman", "America/Argentina/Ushuaia", "America/Aruba", "America/Asuncion", "America/Atikokan", "America/Atka", "America/Bahia", "America/Bahia_Banderas", "America/Barbados", "America/Belem", "America/Belize", "America/Blanc-Sablon", "America/Boa_Vista", "America/Bogota", "America/Boise", "America/Buenos_Aires", "America/Cambridge_Bay", "America/Campo_Grande", "America/Cancun", "America/Caracas", "America/Catamarca", "America/Cayenne", "America/Cayman", "America/Chicago", "America/Chihuahua", "America/Ciudad_Juarez", "America/Coral_Harbour", "America/Cordoba", "America/Costa_Rica", "America/Coyhaique", "America/Creston", "America/Cuiaba", "America/Curacao", "America/Danmarkshavn", "America/Dawson", "America/Dawson_Creek", "America/Denver", "America/Detroit", "America/Dominica", "America/Edmonton", "America/Eirunepe", "America/El_Salvador", "America/Ensenada", "America/Fort_Nelson", "America/Fort_Wayne", "America/Fortaleza", "America/Glace_Bay", "America/Godthab", "America/Goose_Bay", "America/Grand_Turk", "America/Grenada", "America/Guadeloupe", "America/Guatemala", "America/Guayaquil", "America/Guyana", "America/Halifax", "America/Havana", "America/Hermosillo", "America/Indiana/Indianapolis", "America/Indiana/Knox", "America/Indiana/Marengo", "America/Indiana/Petersburg", "America/Indiana/Tell_City", "America/Indiana/Vevay", "America/Indiana/Vincennes", "America/Indiana/Winamac", "America/Indianapolis", "America/Inuvik", "America/Iqaluit", "America/Jamaica", "America/Jujuy", "America/Juneau", "America/Kentucky/Louisville", "America/Kentucky/Monticello", "America/Knox_IN", "America/Kralendijk", "America/La_Paz", "America/Lima", "America/Los_Angeles", "America/Louisville", "America/Lower_Princes", "America/Maceio", "America/Managua", "America/Manaus", "America/Marigot", "America/Martinique", "America/Matamoros", "America/Mazatlan", "America/Mendoza", "America/Menominee", "America/Merida", "America/Metlakatla", "America/Mexico_City", "America/Miquelon", "America/Moncton", "America/Monterrey", "America/Montevideo", "America/Montreal", "America/Montserrat", "America/Nassau", "America/New_York", "America/Nipigon", "America/Nome", "America/Noronha", "America/North_Dakota/Beulah", "America/North_Dakota/Center", "America/North_Dakota/New_Salem", "America/Nuuk", "America/Ojinaga", "America/Panama", "America/Pangnirtung", "America/Paramaribo", "America/Phoenix", "America/Port-au-Prince", "America/Port_of_Spain", "America/Porto_Acre", "America/Porto_Velho", "America/Puerto_Rico", "America/Punta_Arenas", "America/Rainy_River", "America/Rankin_Inlet", "America/Recife", "America/Regina", "America/Resolute", "America/Rio_Branco", "America/Rosario", "America/Santa_Isabel", "America/Santarem", "America/Santiago", "America/Santo_Domingo", "America/Sao_Paulo", "America/Scoresbysund", "America/Shiprock", "America/Sitka", "America/St_Barthelemy", "America/St_Johns", "America/St_Kitts", "America/St_Lucia", "America/St_Thomas", "America/St_Vincent", "America/Swift_Current", "America/Tegucigalpa", "America/Thule", "America/Thunder_Bay", "America/Tijuana", "America/Toronto", "America/Tortola", "America/Vancouver", "America/Virgin", "America/Whitehorse", "America/Winnipeg", "America/Yakutat", "America/Yellowknife", "Antarctica/Casey", "Antarctica/Davis", "Antarctica/DumontDUrville", "Antarctica/Macquarie", "Antarctica/Mawson", "Antarctica/McMurdo", "Antarctica/Palmer", "Antarctica/Rothera", "Antarctica/South_Pole", "Antarctica/Syowa", "Antarctica/Troll", "Antarctica/Vostok", "Arctic/Longyearbyen", "Asia/Aden", "Asia/Almaty", "Asia/Amman", "Asia/Anadyr", "Asia/Aqtau", "Asia/Aqtobe", "Asia/Ashgabat", "Asia/Ashkhabad", "Asia/Atyrau", "Asia/Baghdad", "Asia/Bahrain", "Asia/Baku", "Asia/Bangkok", "Asia/Barnaul", "Asia/Beirut", "Asia/Bishkek", "Asia/Brunei", "Asia/Calcutta", "Asia/Chita", "Asia/Choibalsan", "Asia/Chongqing", "Asia/Chungking", "Asia/Colombo", "Asia/Dacca", "Asia/Damascus", "Asia/Dhaka", "Asia/Dili", "Asia/Dubai", "Asia/Dushanbe", "Asia/Famagusta", "Asia/Gaza", "Asia/Harbin", "Asia/Hebron", "Asia/Ho_Chi_Minh", "Asia/Hong_Kong", "Asia/Hovd", "Asia/Irkutsk", "Asia/Istanbul", "Asia/Jakarta", "Asia/Jayapura", "Asia/Jerusalem", "Asia/Kabul", "Asia/Kamchatka", "Asia/Karachi", "Asia/Kashgar", "Asia/Kathmandu", "Asia/Katmandu", "Asia/Khandyga", "Asia/Kolkata", "Asia/Krasnoyarsk", "Asia/Kuala_Lumpur", "Asia/Kuching", "Asia/Kuwait", "Asia/Macao", "Asia/Macau", "Asia/Magadan", "Asia/Makassar", "Asia/Manila", "Asia/Muscat", "Asia/Nicosia", "Asia/Novokuznetsk", "Asia/Novosibirsk", "Asia/Omsk", "Asia/Oral", "Asia/Phnom_Penh", "Asia/Pontianak", "Asia/Pyongyang", "Asia/Qatar", "Asia/Qostanay", "Asia/Qyzylorda", "Asia/Rangoon", "Asia/Riyadh", "Asia/Saigon", "Asia/Sakhalin", "Asia/Samarkand", "Asia/Seoul", "Asia/Shanghai", "Asia/Singapore", "Asia/Srednekolymsk", "Asia/Taipei", "Asia/Tashkent", "Asia/Tbilisi", "Asia/Tehran", "Asia/Tel_Aviv", "Asia/Thimbu", "Asia/Thimphu", "Asia/Tokyo", "Asia/Tomsk", "Asia/Ujung_Pandang", "Asia/Ulaanbaatar", "Asia/Ulan_Bator", "Asia/Urumqi", "Asia/Ust-Nera", "Asia/Vientiane", "Asia/Vladivostok", "Asia/Yakutsk", "Asia/Yangon", "Asia/Yekaterinburg", "Asia/Yerevan", "Atlantic/Azores", "Atlantic/Bermuda", "Atlantic/Canary", "Atlantic/Cape_Verde", "Atlantic/Faeroe", "Atlantic/Faroe", "Atlantic/Jan_Mayen", "Atlantic/Madeira", "Atlantic/Reykjavik", "Atlantic/South_Georgia", "Atlantic/St_Helena", "Atlantic/Stanley", "Australia/ACT", "Australia/Adelaide", "Australia/Brisbane", "Australia/Broken_Hill", "Australia/Canberra", "Australia/Currie", "Australia/Darwin", "Australia/Eucla", "Australia/Hobart", "Australia/LHI", "Australia/Lindeman", "Australia/Lord_Howe", "Australia/Melbourne", "Australia/NSW", "Australia/North", "Australia/Perth", "Australia/Queensland", "Australia/South", "Australia/Sydney", "Australia/Tasmania", "Australia/Victoria", "Australia/West", "Australia/Yancowinna", "BET", "BST", "Brazil/Acre", "Brazil/DeNoronha", "Brazil/East", "Brazil/West", "CAT", "CET", "CNT", "CST", "CST6CDT", "CTT", "Canada/Atlantic", "Canada/Central", "Canada/Eastern", "Canada/Mountain", "Canada/Newfoundland", "Canada/Pacific", "Canada/Saskatchewan", "Canada/Yukon", "Chile/Continental", "Chile/EasterIsland", "Cuba", "EAT", "ECT", "EET", "EST", "EST5EDT", "Egypt", "Eire", "Etc/GMT", "Etc/GMT+0", "Etc/GMT+1", "Etc/GMT+10", "Etc/GMT+11", "Etc/GMT+12", "Etc/GMT+2", "Etc/GMT+3", "Etc/GMT+4", "Etc/GMT+5", "Etc/GMT+6", "Etc/GMT+7", "Etc/GMT+8", "Etc/GMT+9", "Etc/GMT-0", "Etc/GMT-1", "Etc/GMT-10", "Etc/GMT-11", "Etc/GMT-12", "Etc/GMT-13", "Etc/GMT-14", "Etc/GMT-2", "Etc/GMT-3", "Etc/GMT-4", "Etc/GMT-5", "Etc/GMT-6", "Etc/GMT-7", "Etc/GMT-8", "Etc/GMT-9", "Etc/GMT0", "Etc/Greenwich", "Etc/UCT", "Etc/UTC", "Etc/Universal", "Etc/Zulu", "Europe/Amsterdam", "Europe/Andorra", "Europe/Astrakhan", "Europe/Athens", "Europe/Belfast", "Europe/Belgrade", "Europe/Berlin", "Europe/Bratislava", "Europe/Brussels", "Europe/Bucharest", "Europe/Budapest", "Europe/Busingen", "Europe/Chisinau", "Europe/Copenhagen", "Europe/Dublin", "Europe/Gibraltar", "Europe/Guernsey", "Europe/Helsinki", "Europe/Isle_of_Man", "Europe/Istanbul", "Europe/Jersey", "Europe/Kaliningrad", "Europe/Kiev", "Europe/Kirov", "Europe/Kyiv", "Europe/Lisbon", "Europe/Ljubljana", "Europe/London", "Europe/Luxembourg", "Europe/Madrid", "Europe/Malta", "Europe/Mariehamn", "Europe/Minsk", "Europe/Monaco", "Europe/Moscow", "Europe/Nicosia", "Europe/Oslo", "Europe/Paris", "Europe/Podgorica", "Europe/Prague", "Europe/Riga", "Europe/Rome", "Europe/Samara", "Europe/San_Marino", "Europe/Sarajevo", "Europe/Saratov", "Europe/Simferopol", "Europe/Skopje", "Europe/Sofia", "Europe/Stockholm", "Europe/Tallinn", "Europe/Tirane", "Europe/Tiraspol", "Europe/Ulyanovsk", "Europe/Uzhgorod", "Europe/Vaduz", "Europe/Vatican", "Europe/Vienna", "Europe/Vilnius", "Europe/Volgograd", "Europe/Warsaw", "Europe/Zagreb", "Europe/Zaporozhye", "Europe/Zurich", "GB", "GB-Eire", "GMT", "GMT0", "Greenwich", "HST", "Hongkong", "IET", "IST", "Iceland", "Indian/Antananarivo", "Indian/Chagos", "Indian/Christmas", "Indian/Cocos", "Indian/Comoro", "Indian/Kerguelen", "Indian/Mahe", "Indian/Maldives", "Indian/Mauritius", "Indian/Mayotte", "Indian/Reunion", "Iran", "Israel", "JST", "Jamaica", "Japan", "Kwajalein", "Libya", "MET", "MIT", "MST", "MST7MDT", "Mexico/BajaNorte", "Mexico/BajaSur", "Mexico/General", "NET", "NST", "NZ", "NZ-CHAT", "Navajo", "PLT", "PNT", "PRC", "PRT", "PST", "PST8PDT", "Pacific/Apia", "Pacific/Auckland", "Pacific/Bougainville", "Pacific/Chatham", "Pacific/Chuuk", "Pacific/Easter", "Pacific/Efate", "Pacific/Enderbury", "Pacific/Fakaofo", "Pacific/Fiji", "Pacific/Funafuti", "Pacific/Galapagos", "Pacific/Gambier", "Pacific/Guadalcanal", "Pacific/Guam", "Pacific/Honolulu", "Pacific/Johnston", "Pacific/Kanton", "Pacific/Kiritimati", "Pacific/Kosrae", "Pacific/Kwajalein", "Pacific/Majuro", "Pacific/Marquesas", "Pacific/Midway", "Pacific/Nauru", "Pacific/Niue", "Pacific/Norfolk", "Pacific/Noumea", "Pacific/Pago_Pago", "Pacific/Palau", "Pacific/Pitcairn", "Pacific/Pohnpei", "Pacific/Ponape", "Pacific/Port_Moresby", "Pacific/Rarotonga", "Pacific/Saipan", "Pacific/Samoa", "Pacific/Tahiti", "Pacific/Tarawa", "Pacific/Tongatapu", "Pacific/Truk", "Pacific/Wake", "Pacific/Wallis", "Pacific/Yap", "Poland", "Portugal", "ROK", "SST", "Singapore", "SystemV/AST4", "SystemV/AST4ADT", "SystemV/CST6", "SystemV/CST6CDT", "SystemV/EST5", "SystemV/EST5EDT", "SystemV/HST10", "SystemV/MST7", "SystemV/MST7MDT", "SystemV/PST8", "SystemV/PST8PDT", "SystemV/YST9", "SystemV/YST9YDT", "Turkey", "UCT", "US/Alaska", "US/Aleutian", "US/Arizona", "US/Central", "US/East-Indiana", "US/Eastern", "US/Hawaii", "US/Indiana-Starke", "US/Michigan", "US/Mountain", "US/Pacific", "US/Samoa", "UTC", "Universal", "VST", "W-SU", "WET", "Zulu"}; + private static long binarySearchTransition(TimeZone tz, long lo, long hi) { + int loOffset = tz.getOffset(lo); + while (hi - lo > 1) { + long mid = lo + (hi - lo) / 2; + if (tz.getOffset(mid) == loOffset) { + lo = mid; + } else { + hi = mid; + } + } + return hi; + } /** - * This method is only used to generate the timezone info file for maintenance purpose. + * Decode a UTC transition instant into (month, day, dayOfWeek, timeInDay, mode). + * Returns [month(0-11), day, dayOfWeek(1-7), timeMs, mode(0-3)]. * - * The generated file is based on OpenJDK 8's `sun.util.calendar.ZoneInfo` implementation. - * Since `ZoneInfo` is not public API, on some JDK distributions (like Oracle JDK), - * it's not accessible. So we comment the method out to avoid build issues. + * We encode recurring weekday rules as DOW_GE_DOM_MODE (mode=2). + * For nth-weekday rules, the base day is the earliest possible day of that + * occurrence in the month: + * 1st => 1, 2nd => 8, 3rd => 15, 4th => 22. + * For last-weekday rules, the base day is the earliest day of the final week + * in the month, i.e. {@code monthLength - 6}. * - * File format: - * - First N * 4 bytes: N is number of timezone Ids - * - each 4 bytes is the offset of the timezone info in the file - * - Then each timezone info: - * - 4 bytes: rawOffset (int) - * - 4 bytes: numTransitions (int) - * - numTransitions * 8 bytes: transitions (long[]) - * - 4 bytes: numOffsets (int) - * - numOffsets * 4 bytes: offsets (int[]) + * This mirrors encodings such as "Sun >= 8" for the second Sunday in March + * and "Sun >= 25" for the last Sunday in October. + */ + private static int[] decodeTransition(long utcMs, int rawOffsetMs) { + // Convert UTC ms to standard local time + long localMs = utcMs + rawOffsetMs; + java.time.Instant instant = java.time.Instant.ofEpochMilli(localMs); + java.time.LocalDateTime ldt = java.time.LocalDateTime.ofInstant( + instant, java.time.ZoneOffset.UTC); + + int month = ldt.getMonthValue() - 1; // 0-based for Calendar compat + int dayOfMonth = ldt.getDayOfMonth(); + // Calendar: 1=Sun..7=Sat + int dayOfWeek = toCalendarDayOfWeek(ldt.getDayOfWeek().getValue()); + int timeInDay = ldt.getHour() * 3600_000 + ldt.getMinute() * 60_000 + + ldt.getSecond() * 1000 + ldt.getNano() / 1_000_000; + + int monthLength = ldt.toLocalDate().lengthOfMonth(); + int dayOfWeekInMonth = (dayOfMonth - 1) / 7 + 1; + boolean isLastOccurrence = dayOfMonth + 7 > monthLength; + int baseDayOfMonth = isLastOccurrence ? + monthLength - 6 : + 1 + (dayOfWeekInMonth - 1) * 7; + + // DOW_GE_DOM: first on or after + return new int[]{month, baseDayOfMonth, dayOfWeek, timeInDay, 2}; + } + + /** + * Verify the extracted DST rule matches JVM getOffset() around transition boundaries + * and at monthly sample points for refYear +/- 1 (3 years). + * + * DST rule mismatches only manifest at transition boundaries, so we check + * +/- 12 hours around each computed transition plus one sample per month. + * This reduces ~52K getOffset() calls per verification to ~200. + */ + private static boolean verifyDstRule(TimeZone tz, DstRule rule, int refYear) { + int rawOffsetMs = tz.getRawOffset(); + for (int y = refYear - 1; y <= refYear + 1; y++) { + long dstStart = computeTransitionUtcMillis(y, rule.startMonth, rule.startDay, + rule.startDayOfWeek, rule.startTime, rule.startTimeMode, rule.startMode, + rawOffsetMs, rule.dstSavings, true); + long dstEnd = computeTransitionUtcMillis(y, rule.endMonth, rule.endDay, + rule.endDayOfWeek, rule.endTime, rule.endTimeMode, rule.endMode, + rawOffsetMs, rule.dstSavings, false); + + // Check +/- 12 hours around each transition at hourly granularity + long[] boundaries = {dstStart, dstEnd}; + for (long boundary : boundaries) { + long from = boundary - 12 * 3600_000L; + long to = boundary + 12 * 3600_000L; + for (long ms = from; ms <= to; ms += 3600_000L) { + if (tz.getOffset(ms) != computeDstOffset(ms, rawOffsetMs, rule)) { + return false; + } + } + } + + // Monthly mid-period spot checks (1st of each month at noon UTC) + for (int m = 0; m < 12; m++) { + long ms = utcMillisForDate(y, m, 1) + 12 * 3600_000L; + if (tz.getOffset(ms) != computeDstOffset(ms, rawOffsetMs, rule)) { + return false; + } + } + } + return true; + } + + private static int computeDstOffset(long utcMs, int rawOffsetMs, DstRule rule) { + int year = LocalDate.ofEpochDay(Math.floorDiv(utcMs + rawOffsetMs, 86_400_000L)).getYear(); + long dstStart = computeTransitionUtcMillis(year, rule.startMonth, rule.startDay, + rule.startDayOfWeek, rule.startTime, rule.startTimeMode, rule.startMode, + rawOffsetMs, rule.dstSavings, true); + long dstEnd = computeTransitionUtcMillis(year, rule.endMonth, rule.endDay, + rule.endDayOfWeek, rule.endTime, rule.endTimeMode, rule.endMode, + rawOffsetMs, rule.dstSavings, false); + + boolean inDst = dstStart < dstEnd ? + (utcMs >= dstStart && utcMs < dstEnd) : + (utcMs >= dstStart || utcMs < dstEnd); + return inDst ? rawOffsetMs + rule.dstSavings : rawOffsetMs; + } + + private static long computeTransitionUtcMillis(int year, int ruleMonth, int ruleDay, + int ruleDayOfWeek, int ruleTime, int ruleTimeMode, int ruleMode, int rawOffsetMs, + int dstSavingsMs, boolean isStartRule) { + int actualDay = computeRuleDay(ruleMode, ruleDay, ruleDayOfWeek, year, ruleMonth); + long utcMs = utcMillisForDate(year, ruleMonth, actualDay) + ruleTime; + if (ruleTimeMode == 0) { + utcMs -= rawOffsetMs; + if (!isStartRule) { + utcMs -= dstSavingsMs; + } + } else if (ruleTimeMode == 1) { + utcMs -= rawOffsetMs; + } + return utcMs; + } + + private static int computeRuleDay(int ruleMode, int ruleDay, int ruleDayOfWeek, int year, + int month) { + LocalDate firstOfMonth = LocalDate.of(year, month + 1, 1); + int monthLength = firstOfMonth.lengthOfMonth(); + int firstDayOfWeek = toCalendarDayOfWeek(firstOfMonth.getDayOfWeek().getValue()); + + switch (ruleMode) { + case 1: { + if (ruleDay > 0) { + int diff = ruleDayOfWeek - firstDayOfWeek; + if (diff < 0) { + diff += 7; + } + return 1 + diff + (ruleDay - 1) * 7; + } else { + int lastDayOfWeek = toCalendarDayOfWeek( + LocalDate.of(year, month + 1, monthLength).getDayOfWeek().getValue()); + int diff = lastDayOfWeek - ruleDayOfWeek; + if (diff < 0) { + diff += 7; + } + return monthLength - diff + (ruleDay + 1) * 7; + } + } + case 2: { + int targetDayOfWeek = toCalendarDayOfWeek( + LocalDate.of(year, month + 1, ruleDay).getDayOfWeek().getValue()); + int diff = ruleDayOfWeek - targetDayOfWeek; + if (diff < 0) { + diff += 7; + } + return ruleDay + diff; + } + case 3: { + int targetDayOfWeek = toCalendarDayOfWeek( + LocalDate.of(year, month + 1, ruleDay).getDayOfWeek().getValue()); + int diff = targetDayOfWeek - ruleDayOfWeek; + if (diff < 0) { + diff += 7; + } + return ruleDay - diff; + } + default: + return ruleDay; + } + } + + private static long utcMillisForDate(int year, int month, int day) { + return LocalDate.of(year, month + 1, day).toEpochDay() * 24L * 3600_000L; + } + + @Override + public String toString() { + return "OrcTimezoneInfo{" + + "initialOffset=" + initialOffset + + ", rawOffset=" + rawOffset + + ", transitions=" + Arrays.toString(transitions) + + ", offsets=" + Arrays.toString(offsets) + + '}'; + } + + private static final ConcurrentMap RUNTIME_TIMEZONE_INFOS = + new ConcurrentHashMap<>(); + + /** + * Get timezone info for the specified timezone ID. + * Historical transitions are generated at runtime from public JVM APIs and cached per ID. * - * How to do the maintenance: - * - update the `timezoneIds` via TimeZone.getAvailableIDs() and sort them. - * - run this method to generate the timezone info file, and copy the file to resources folder. + * @param timezoneId timezone Id + * @return timezone info with DST rules + */ + public static OrcTimezoneInfo get(String timezoneId) { + return RUNTIME_TIMEZONE_INFOS.computeIfAbsent( + timezoneId, + OrcTimezoneInfo::buildRuntimeOrcTimezoneInfo); + } + + /** + * Build ORC timezone metadata from public java.time/java.util APIs. Invalid IDs use the same + * validation as {@link GpuTimeZoneDB#getZoneId(String)} and fail with + * {@link IllegalArgumentException} (no silent fallback to GMT). */ - public static void serializeTimezoneInfo() { -// try { -// String path = "/tmp/orc_timezone_info.data"; -// -// // sort timezone ids -// String[] ids = TimeZone.getAvailableIDs(); -// ArrayList sortedIds = new ArrayList<>(Arrays.asList(ids)); -// sortedIds.sort(String::compareTo); -// -// List timezoneOffsets = new ArrayList<>(); -// DataOutputStream out = new DataOutputStream(Files.newOutputStream(Paths.get(path))); -// -// // from ZoneInfo source code -// long OFFSET_MASK_IN_ZONE_INFO = 0x0FL; -// int TRANSITION_NSHIFT_IN_ZONE_INFO = 12; -// -// // collect offsets for each timezone -// int timezoneOffsetInFile = 0; -// for (String id : sortedIds) { -// timezoneOffsets.add(timezoneOffsetInFile); -// -// ZoneInfo zoneInfo = (ZoneInfo) TimeZone.getTimeZone(id); -// long[] trans = (long[]) FieldUtils.readField(zoneInfo, "transitions"); -// int numTransitions = trans == null ? 0 : trans.length; -// -// // timezone serialized size calculation -// timezoneOffsetInFile += 4; // rawOffset -// timezoneOffsetInFile += 4; // numTransitions -// timezoneOffsetInFile += numTransitions * 8; // transitions longs -// timezoneOffsetInFile += 4; // numOffsets -// timezoneOffsetInFile += numTransitions * 4; // offsets ints -// } -// -// // First write all timezone offsets in the file -// int totalOffsetIndicesSize = sortedIds.size() * 4; -// for (int off : timezoneOffsets) { -// out.writeInt(off + totalOffsetIndicesSize); -// } -// -// // Then write each timezone info -// for (String id : sortedIds) { -// ZoneInfo zoneInfo = (ZoneInfo) TimeZone.getTimeZone(id); -// long[] trans = (long[]) FieldUtils.readField(zoneInfo, "transitions"); -// int[] offs = (int[]) FieldUtils.readField(zoneInfo, "offsets"); -// int rawOff = (int) FieldUtils.readField(zoneInfo, "rawOffset"); -// -// int numTransitions = trans == null ? 0 : trans.length; -// -// long[] actualTrans = new long[numTransitions]; -// int[] actualOffsets = new int[numTransitions]; -// for (int i = 0; i < numTransitions; ++i) { -// // `trans` is combination of transition and offset index -// actualTrans[i] = trans[i] >> TRANSITION_NSHIFT_IN_ZONE_INFO; -// // the `offs` is a dictionary, get the actual offset value via index -// // `trans[i] & OFFSET_MASK_IN_ZONE_INFO` is to get offset index -// actualOffsets[i] = offs[(int) (trans[i] & OFFSET_MASK_IN_ZONE_INFO)]; -// } -// -// out.writeInt(rawOff); -// -// out.writeInt(numTransitions); -// for (long t : actualTrans) { -// out.writeLong(t); -// } -// -// out.writeInt(numTransitions); -// for (int o : actualOffsets) { -// out.writeInt(o); -// } -// } -// out.flush(); -// out.close(); -// } catch (Exception e) { -// throw new RuntimeException("Failed to serialize ORC timezone info.", e); -// } + private static OrcTimezoneInfo buildRuntimeOrcTimezoneInfo(String timezoneId) { + final ZoneId zoneId; + try { + zoneId = GpuTimeZoneDB.getZoneId(timezoneId); + } catch (DateTimeException e) { + throw new IllegalArgumentException("Timezone ID not found: " + timezoneId, e); + } + + TimeZone tz = TimeZone.getTimeZone(timezoneId); + ZoneRules rules = zoneId.getRules(); + int initialOffset = getInitialOffset(tz); + if (rules.isFixedOffset()) { + return new OrcTimezoneInfo(initialOffset, tz.getRawOffset(), null, null, null); + } + DstRule dstRule = extractDstRule(timezoneId, tz); + + List transitionList = rules.getTransitions(); + HistoricalTransitions historicalTransitions = buildHistoricalTransitions(tz, transitionList); + if (historicalTransitions.transitions == null) { + return new OrcTimezoneInfo(initialOffset, tz.getRawOffset(), null, null, dstRule); + } + return new OrcTimezoneInfo(initialOffset, tz.getRawOffset(), + historicalTransitions.transitions, historicalTransitions.offsets, dstRule); + } + + public static List getAllTimezoneIds() { + + String[] ids = TimeZone.getAvailableIDs(); + Arrays.sort(ids); + return Arrays.asList(ids); + } + + private static int getInitialOffset(TimeZone tz) { + // ORC only supports timestamps from year 0001 onward. For dates before the + // first historical transition in that range, java.util.TimeZone can differ + // from ZoneRules' earliest wall offset (for example, it may use the zone's + // standard raw offset instead of an older LMT offset). Sample the beginning + // of the supported range so the GPU matches TimeZone.getOffset(). + return tz.getOffset(MIN_SUPPORTED_ORC_UTC_MILLIS); + } + + private static HistoricalTransitions buildHistoricalTransitions( + TimeZone tz, + List transitionList) { + if (transitionList.isEmpty()) { + return HistoricalTransitions.EMPTY; + } + + List transitions = new ArrayList<>(); + List offsets = new ArrayList<>(); + long scanCursor = MIN_SUPPORTED_ORC_UTC_MILLIS; + int currentOffset = getInitialOffset(tz); + + for (ZoneOffsetTransition transition : transitionList) { + long transitionMs = transition.getInstant().toEpochMilli(); + if (transitionMs < MIN_SUPPORTED_ORC_UTC_MILLIS) { + continue; + } + + long beforeTransitionMs = transitionMs - 1; + int offsetBeforeTransition = tz.getOffset(beforeTransitionMs); + if (beforeTransitionMs >= scanCursor && offsetBeforeTransition != currentOffset) { + currentOffset = collectTimeZoneTransitionsByScanning( + tz, scanCursor, beforeTransitionMs, currentOffset, transitions, offsets); + } + + int offsetAtTransition = tz.getOffset(transitionMs); + if (offsetAtTransition != offsetBeforeTransition) { + transitions.add(transitionMs); + offsets.add(offsetAtTransition); + currentOffset = offsetAtTransition; + } + scanCursor = transitionMs; + } + + if (transitions.isEmpty()) { + return HistoricalTransitions.EMPTY; + } + return new HistoricalTransitions(toLongArray(transitions), toIntArray(offsets)); + } + + private static int collectTimeZoneTransitionsByScanning( + TimeZone tz, + long scanStartMs, + long scanEndMs, + int startOffset, + List transitions, + List offsets) { + long cursor = scanStartMs; + int currentOffset = startOffset; + while (cursor < scanEndMs) { + long probe = Math.min(cursor + HISTORICAL_TRANSITION_SCAN_STEP_MILLIS, scanEndMs); + int probeOffset = tz.getOffset(probe); + if (probeOffset == currentOffset) { + cursor = probe; + continue; + } + + long exactTransition = binarySearchTransition(tz, cursor, probe); + int offsetAfterTransition = tz.getOffset(exactTransition); + transitions.add(exactTransition); + offsets.add(offsetAfterTransition); + currentOffset = offsetAfterTransition; + cursor = exactTransition; + } + return currentOffset; + } + + private static long[] toLongArray(List values) { + long[] result = new long[values.size()]; + for (int i = 0; i < values.size(); i++) { + result[i] = values.get(i); + } + return result; + } + + private static int[] toIntArray(List values) { + int[] result = new int[values.size()]; + for (int i = 0; i < values.size(); i++) { + result[i] = values.get(i); + } + return result; + } + + private static final class HistoricalTransitions { + static final HistoricalTransitions EMPTY = new HistoricalTransitions(null, null); + + final long[] transitions; + final int[] offsets; + + private HistoricalTransitions(long[] transitions, int[] offsets) { + this.transitions = transitions; + this.offsets = offsets; + } } } diff --git a/src/main/resources/orc_timezone_info.data b/src/main/resources/orc_timezone_info.data deleted file mode 100644 index 38b0fb56dcc6d6901308d3c747126e36c7e37983..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 544076 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

J%`_`@t0CV%U=bvvmgkD7Fn1J4b=l!B*)EEG`u; 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�Yl)!xnH&&CQbr3VrrfG1C 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 diff --git a/src/test/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDBTest.java b/src/test/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDBTest.java index c49f5de687..ec9827cece 100644 --- a/src/test/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDBTest.java +++ b/src/test/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDBTest.java @@ -21,25 +21,36 @@ import org.junit.jupiter.api.Test; import static ai.rapids.cudf.AssertUtils.assertColumnsAreEqual; +import static org.junit.jupiter.api.Assumptions.assumeTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertFalse; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneOffset; +import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import java.util.List; import java.util.Random; +import java.util.Set; import java.util.TimeZone; import java.util.concurrent.TimeUnit; public class GpuTimeZoneDBTest { private static final long microsPerMillis = TimeUnit.MILLISECONDS.toMicros(1); + private static final String EXHAUSTIVE_HALF_HOUR_TEST_PROPERTY = + "spark.rapids.tests.orc.exhaustiveHalfHour"; + private static final String EXHAUSTIVE_HALF_HOUR_BATCH_SIZE_PROPERTY = + "spark.rapids.tests.orc.exhaustiveHalfHour.batchSize"; + private static final int DEFAULT_EXHAUSTIVE_HALF_HOUR_BATCH_SIZE = 16_384; /** * Java implementation of timezone conversion to compare against the GPU * results. - * Refer to https://github.com/apache/orc/blob/rel/release-1.9.1/java/core/ - * src/java/org/apache/orc/impl/SerializationUtils.java#L1440 + * Refer to ORC code link + * */ private static ColumnVector convertOrcTimezonesOnCPU( long[] microseconds, @@ -60,12 +71,106 @@ private static ColumnVector convertOrcTimezonesOnCPU( return ColumnVector.timestampMicroSecondsFromLongs(results); } + private static long microsUtc(LocalDateTime timestamp) { + return timestamp.toEpochSecond(ZoneOffset.UTC) * TimeUnit.SECONDS.toMicros(1); + } + + private static long[] microsUtc(LocalDateTime... timestamps) { + long[] result = new long[timestamps.length]; + for (int i = 0; i < timestamps.length; ++i) { + result[i] = microsUtc(timestamps[i]); + } + return result; + } + + private static void assertOrcConversionMatchesCpu(long[] microseconds, String[][] cases) { + for (String[] timezones : cases) { + try (ColumnVector input = ColumnVector.timestampMicroSecondsFromLongs(microseconds); + ColumnVector expected = convertOrcTimezonesOnCPU(microseconds, timezones[0], timezones[1]); + ColumnVector actual = GpuTimeZoneDB.convertOrcTimezones(input, 0L, timezones[0], timezones[1])) { + assertColumnsAreEqual(expected, actual); + } + } + } + + private static long[] collectTransitionBoundaryMicros(String timezoneId, int year) { + TimeZone tz = TimeZone.getTimeZone(timezoneId); + long start = microsUtc(LocalDateTime.of(year, 1, 1, 0, 0)) / microsPerMillis; + long end = microsUtc(LocalDateTime.of(year + 1, 1, 1, 0, 0)) / microsPerMillis; + long step = TimeUnit.HOURS.toMillis(1); + int prevOffset = tz.getOffset(start - 1); + List samples = new ArrayList<>(); + + for (long millis = start; millis < end; millis += step) { + int offset = tz.getOffset(millis); + if (offset == prevOffset) { + continue; + } + long transition = binarySearchTransition(tz, millis - step, millis); + for (long deltaMillis : new long[]{-1L, 0L, 1L}) { + samples.add((transition + deltaMillis) * microsPerMillis); + } + prevOffset = offset; + } + + long[] result = new long[samples.size()]; + for (int i = 0; i < samples.size(); ++i) { + result[i] = samples.get(i); + } + return result; + } + + private static long binarySearchTransition(TimeZone tz, long lo, long hi) { + int loOffset = tz.getOffset(lo); + while (hi - lo > 1) { + long mid = lo + (hi - lo) / 2; + if (tz.getOffset(mid) == loOffset) { + lo = mid; + } else { + hi = mid; + } + } + return hi; + } + + private static long[] concat(long[]... arrays) { + int total = 0; + for (long[] array : arrays) { + total += array.length; + } + long[] result = new long[total]; + int offset = 0; + for (long[] array : arrays) { + System.arraycopy(array, 0, result, offset, array.length); + offset += array.length; + } + return result; + } + + private static void assertOrcConversionMatchesCpuForOrderedPairs( + long[] microseconds, + String[] timezones) { + for (String writerTz : timezones) { + for (String readerTz : timezones) { + if (writerTz.equals(readerTz)) { + continue; + } + try (ColumnVector input = ColumnVector.timestampMicroSecondsFromLongs(microseconds); + ColumnVector expected = convertOrcTimezonesOnCPU(microseconds, writerTz, readerTz); + ColumnVector actual = GpuTimeZoneDB.convertOrcTimezones(input, 0L, writerTz, readerTz)) { + assertColumnsAreEqual(expected, actual); + } + } + } + } + @Test void testConvertOrcTimezones() { GpuTimeZoneDB.cacheDatabase(); GpuTimeZoneDB.verifyDatabaseCached(); - // test time range: (0001-01-01 00:00:00, 9999-12-31 23:59:59) + // Full range: (0001-01-01 00:00:00, 9999-12-31 23:59:59) + // DST rules from SimpleTimeZone handle dates beyond the transition table. long min = LocalDateTime.of(1, 1, 1, 0, 0, 0) .toEpochSecond(ZoneOffset.UTC) * TimeUnit.SECONDS.toMicros(1); long max = LocalDateTime.of(9999, 12, 31, 23, 59, 59) @@ -84,15 +189,7 @@ void testConvertOrcTimezones() { "Asia/Tokyo"); for (String writerTz : timezones) { - if (GpuTimeZoneDB.isDST(writerTz)) { - // currently do not support DST conversions - continue; - } for (String readerTz : timezones) { - if (GpuTimeZoneDB.isDST(readerTz)) { - // currently do not support DST conversions - continue; - } // Use 1024 as a reasonable batch size for testing timezone conversions. long[] microseconds = new long[1024]; for (int i = 0; i < microseconds.length; ++i) { @@ -104,10 +201,259 @@ void testConvertOrcTimezones() { // Convert on CPU ColumnVector expected = convertOrcTimezonesOnCPU(microseconds, writerTz, readerTz); // Convert on GPU - ColumnVector actual = GpuTimeZoneDB.convertOrcTimezones(input, writerTz, readerTz)) { + ColumnVector actual = GpuTimeZoneDB.convertOrcTimezones(input, 0L, writerTz, readerTz)) { assertColumnsAreEqual(expected, actual); } } } } + + @Test + void testConvertOrcTimezonesBeforeFirstTransitionUsesHistoricalOffset() { + GpuTimeZoneDB.cacheDatabase(); + GpuTimeZoneDB.verifyDatabaseCached(); + + long[] microseconds = { + LocalDateTime.of(1, 1, 15, 12, 0, 0) + .toEpochSecond(ZoneOffset.UTC) * TimeUnit.SECONDS.toMicros(1), + LocalDateTime.of(1, 7, 15, 12, 0, 0) + .toEpochSecond(ZoneOffset.UTC) * TimeUnit.SECONDS.toMicros(1), + LocalDateTime.of(1, 12, 15, 12, 0, 0) + .toEpochSecond(ZoneOffset.UTC) * TimeUnit.SECONDS.toMicros(1) + }; + + String[][] cases = { + {"America/Los_Angeles", "UTC"}, + {"UTC", "America/Los_Angeles"}, + {"Australia/Sydney", "UTC"}, + {"UTC", "Australia/Sydney"} + }; + + for (String[] timezones : cases) { + try (ColumnVector input = ColumnVector.timestampMicroSecondsFromLongs(microseconds); + ColumnVector expected = convertOrcTimezonesOnCPU(microseconds, timezones[0], timezones[1]); + ColumnVector actual = GpuTimeZoneDB.convertOrcTimezones(input, 0L, timezones[0], timezones[1])) { + assertColumnsAreEqual(expected, actual); + } + } + } + + @Test + void testConvertOrcTimezonesHistoricalInitialOffsetMismatch() { + GpuTimeZoneDB.cacheDatabase(); + GpuTimeZoneDB.verifyDatabaseCached(); + + long[] microseconds = { + LocalDateTime.of(1899, 12, 31, 23, 59, 59) + .toEpochSecond(ZoneOffset.UTC) * TimeUnit.SECONDS.toMicros(1), + LocalDateTime.of(1900, 1, 1, 0, 0, 0) + .toEpochSecond(ZoneOffset.UTC) * TimeUnit.SECONDS.toMicros(1), + LocalDateTime.of(1900, 1, 1, 12, 0, 0) + .toEpochSecond(ZoneOffset.UTC) * TimeUnit.SECONDS.toMicros(1) + }; + + String[][] cases = { + {"Africa/Windhoek", "UTC"}, + {"UTC", "Africa/Windhoek"} + }; + + for (String[] timezones : cases) { + try (ColumnVector input = ColumnVector.timestampMicroSecondsFromLongs(microseconds); + ColumnVector expected = convertOrcTimezonesOnCPU(microseconds, timezones[0], timezones[1]); + ColumnVector actual = GpuTimeZoneDB.convertOrcTimezones(input, 0L, timezones[0], timezones[1])) { + assertColumnsAreEqual(expected, actual); + } + } + } + + @Test + void testConvertOrcTimezonesHistoricalTransitionBoundaries() { + GpuTimeZoneDB.cacheDatabase(); + GpuTimeZoneDB.verifyDatabaseCached(); + + long[] microseconds = microsUtc( + LocalDateTime.of(1899, 12, 31, 23, 59, 59), + LocalDateTime.of(1900, 1, 1, 0, 0, 0), + LocalDateTime.of(1900, 1, 1, 0, 0, 1), + LocalDateTime.of(1900, 12, 31, 15, 54, 16), + LocalDateTime.of(1900, 12, 31, 15, 54, 17), + LocalDateTime.of(1900, 12, 31, 15, 54, 18), + LocalDateTime.of(1903, 2, 28, 22, 29, 59), + LocalDateTime.of(1903, 2, 28, 22, 30, 0), + LocalDateTime.of(1903, 2, 28, 22, 30, 1) + ); + + String[][] cases = { + {"Asia/Shanghai", "UTC"}, + {"UTC", "Asia/Shanghai"}, + {"Africa/Windhoek", "UTC"}, + {"UTC", "Africa/Windhoek"}, + {"Asia/Shanghai", "Africa/Windhoek"}, + {"Africa/Windhoek", "Asia/Shanghai"} + }; + + assertOrcConversionMatchesCpu(microseconds, cases); + } + + @Test + void testConvertOrcTimezonesFutureDstRuleFallback() { + GpuTimeZoneDB.cacheDatabase(); + GpuTimeZoneDB.verifyDatabaseCached(); + + long[] microseconds = { + LocalDateTime.of(9999, 1, 15, 12, 0, 0) + .toEpochSecond(ZoneOffset.UTC) * TimeUnit.SECONDS.toMicros(1), + LocalDateTime.of(9999, 4, 15, 12, 0, 0) + .toEpochSecond(ZoneOffset.UTC) * TimeUnit.SECONDS.toMicros(1), + LocalDateTime.of(9999, 7, 1, 12, 0, 0) + .toEpochSecond(ZoneOffset.UTC) * TimeUnit.SECONDS.toMicros(1), + LocalDateTime.of(9999, 10, 15, 12, 0, 0) + .toEpochSecond(ZoneOffset.UTC) * TimeUnit.SECONDS.toMicros(1) + }; + + // These zones previously failed the probing-only DST extraction path once + // we moved beyond the static table. Some now rely on far-future probing of + // java.util.TimeZone, while others require the ZoneRules fallback. + String[][] cases = { + {"Asia/Gaza", "UTC"}, + {"UTC", "Asia/Gaza"}, + {"Asia/Jerusalem", "UTC"}, + {"UTC", "Asia/Jerusalem"}, + {"America/Nuuk", "UTC"}, + {"UTC", "America/Nuuk"}, + {"America/Santiago", "UTC"}, + {"UTC", "America/Santiago"} + }; + + for (String[] timezones : cases) { + try (ColumnVector input = ColumnVector.timestampMicroSecondsFromLongs(microseconds); + ColumnVector expected = convertOrcTimezonesOnCPU(microseconds, timezones[0], timezones[1]); + ColumnVector actual = GpuTimeZoneDB.convertOrcTimezones(input, 0L, timezones[0], timezones[1])) { + assertColumnsAreEqual(expected, actual); + } + } + } + + @Test + void testConvertOrcTimezonesFutureDstTransitionBoundaries() { + GpuTimeZoneDB.cacheDatabase(); + GpuTimeZoneDB.verifyDatabaseCached(); + + long[] gazaSamples = collectTransitionBoundaryMicros("Asia/Gaza", 9998); + long[] santiagoSamples = collectTransitionBoundaryMicros("America/Santiago", 9998); + assertFalse(gazaSamples.length == 0, "precondition: expected future transitions for Asia/Gaza"); + assertFalse(santiagoSamples.length == 0, + "precondition: expected future transitions for America/Santiago"); + + long[] microseconds = concat(gazaSamples, santiagoSamples); + + String[][] cases = { + {"Asia/Gaza", "UTC"}, + {"UTC", "Asia/Gaza"}, + {"America/Santiago", "UTC"}, + {"UTC", "America/Santiago"}, + {"Asia/Gaza", "America/Santiago"}, + {"America/Santiago", "Asia/Gaza"} + }; + + assertOrcConversionMatchesCpu(microseconds, cases); + } + + /** + * JVM-valid fixed-offset IDs (e.g. {@code +05:30}) are not returned by + * {@link TimeZone#getAvailableIDs()}, so ORC conversion must synthesize them + * via the runtime metadata path and still match java.util.TimeZone. + */ + @Test + void testConvertOrcTimezonesDynamicFixedOffset() { + GpuTimeZoneDB.cacheDatabase(); + GpuTimeZoneDB.verifyDatabaseCached(); + + Set availableTimeZoneIds = new HashSet<>(GpuTimeZoneDB.getOrcSupportedTimezones()); + String[] customFixedOffsets = {"+05:30", "-02:30", "+14:00", "GMT+08:00"}; + for (String id : customFixedOffsets) { + assertFalse(availableTimeZoneIds.contains(id), + "precondition: ID should require runtime synthesis: " + id); + } + + long min = LocalDateTime.of(1, 1, 1, 0, 0, 0) + .toEpochSecond(ZoneOffset.UTC) * TimeUnit.SECONDS.toMicros(1); + long max = LocalDateTime.of(9999, 12, 31, 23, 59, 59) + .toEpochSecond(ZoneOffset.UTC) * TimeUnit.SECONDS.toMicros(1); + Random rng = new Random(42); + + for (String writerTz : customFixedOffsets) { + for (String readerTz : customFixedOffsets) { + long[] microseconds = new long[256]; + for (int i = 0; i < microseconds.length; ++i) { + microseconds[i] = min + (long) (rng.nextDouble() * (max - min)); + } + try (ColumnVector input = ColumnVector.timestampMicroSecondsFromLongs(microseconds); + ColumnVector expected = convertOrcTimezonesOnCPU(microseconds, writerTz, readerTz); + ColumnVector actual = GpuTimeZoneDB.convertOrcTimezones(input, 0L, writerTz, readerTz)) { + assertColumnsAreEqual(expected, actual); + } + } + } + } + + /** + * Opt-in stress test for exhaustive half-hour ORC timezone rebasing across the + * requested zones. This is intentionally disabled by default because it spans + * every half-hour from year 0000 through year 9999 and validates all ordered + * writer/reader pairs in chunks. + * + * Enable manually with: + * {@code -Dspark.rapids.tests.orc.exhaustiveHalfHour=true} + * + * Optional chunk size override: + * {@code -Dspark.rapids.tests.orc.exhaustiveHalfHour.batchSize=32768} + */ + @Test + void testConvertOrcTimezonesExhaustiveHalfHourNewYorkShanghaiLosAngelesUtc() { + assumeTrue(Boolean.getBoolean(EXHAUSTIVE_HALF_HOUR_TEST_PROPERTY), + "disabled by default; enable with -D" + EXHAUSTIVE_HALF_HOUR_TEST_PROPERTY + "=true"); + + GpuTimeZoneDB.cacheDatabase(); + GpuTimeZoneDB.verifyDatabaseCached(); + + int batchSize = Integer.getInteger( + EXHAUSTIVE_HALF_HOUR_BATCH_SIZE_PROPERTY, + DEFAULT_EXHAUSTIVE_HALF_HOUR_BATCH_SIZE); + assertFalse(batchSize <= 0, "batch size must be positive"); + + String[] timezones = { + "America/New_York", + "Asia/Shanghai", + "America/Los_Angeles", + "UTC" + }; + + long stepMicros = TimeUnit.MINUTES.toMicros(30); + long startMicros = microsUtc(LocalDateTime.of(0, 1, 1, 0, 0)); + long endMicros = microsUtc(LocalDateTime.of(9999, 12, 31, 23, 30)); + + for (long batchStartMicros = startMicros; batchStartMicros <= endMicros; ) { + long remaining = ((endMicros - batchStartMicros) / stepMicros) + 1; + int currentBatchSize = (int) Math.min(batchSize, remaining); + long[] microseconds = new long[currentBatchSize]; + for (int i = 0; i < currentBatchSize; ++i) { + microseconds[i] = batchStartMicros + i * stepMicros; + } + + assertOrcConversionMatchesCpuForOrderedPairs(microseconds, timezones); + batchStartMicros += currentBatchSize * stepMicros; + } + } + + @Test + void testConvertOrcTimezonesInvalidIdStillFails() { + GpuTimeZoneDB.cacheDatabase(); + GpuTimeZoneDB.verifyDatabaseCached(); + long[] one = {0L}; + try (ColumnVector input = ColumnVector.timestampMicroSecondsFromLongs(one)) { + assertThrows(IllegalArgumentException.class, + () -> GpuTimeZoneDB.convertOrcTimezones(input, 0L, "Not/AValidZoneId", "UTC")); + } + } } From da3d94d63447f6f2586553cb82823de8b99c46ba Mon Sep 17 00:00:00 2001 From: Chong Gao Date: Fri, 17 Apr 2026 17:25:48 +0800 Subject: [PATCH 02/22] Fix potential JVM crash when GetIntArrayElements fails in parse_dst_rule GetIntArrayElements returns nullptr and sets a pending OutOfMemoryError when it cannot pin/copy the array (e.g. under memory pressure). Dereferencing that pointer would crash the JVM. Return early with has_dst=false so the pending exception surfaces on JNI exit instead of triggering a SIGSEGV. Signed-off-by: Chong Gao Made-with: Cursor --- src/main/cpp/src/GpuTimeZoneDBJni.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/cpp/src/GpuTimeZoneDBJni.cpp b/src/main/cpp/src/GpuTimeZoneDBJni.cpp index 4f09ccad3d..32b67ecf35 100644 --- a/src/main/cpp/src/GpuTimeZoneDBJni.cpp +++ b/src/main/cpp/src/GpuTimeZoneDBJni.cpp @@ -108,8 +108,11 @@ static spark_rapids_jni::dst_rule parse_dst_rule(JNIEnv* env, jintArray jrule) auto const rule_length = env->GetArrayLength(jrule); JNI_ARG_CHECK( env, rule_length == expected_dst_rule_length, "dst rule array must have 13 ints", rule); + jint* arr = env->GetIntArrayElements(jrule, nullptr); + // GetIntArrayElements returns nullptr and sets a pending OutOfMemoryError + // on failure; return early so the pending exception is raised on JNI exit. + if (arr == nullptr) { return rule; } rule.has_dst = true; - jint* arr = env->GetIntArrayElements(jrule, nullptr); // Order must match GpuTimeZoneDB.dstRuleToArray(): // dstSavings, startMonth, startDay, startDayOfWeek, startTime, // startTimeMode, startMode, endMonth, endDay, endDayOfWeek, From 5ba8afa962091171766996998a3fa0374956780b Mon Sep 17 00:00:00 2001 From: Chong Gao Date: Fri, 17 Apr 2026 17:32:52 +0800 Subject: [PATCH 03/22] Bail out of convertOrcTimezones if parse_dst_rule threw parse_dst_rule can leave a pending Java exception (bad array length or OOM from GetIntArrayElements), but control returns to the caller which would otherwise launch convert_orc_writer_reader_timezones with partially initialized rules and waste GPU work. Check for pending exceptions after each parse_dst_rule call so the outer JNI_CATCH propagates the failure immediately. Signed-off-by: Chong Gao Made-with: Cursor --- src/main/cpp/src/GpuTimeZoneDBJni.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/cpp/src/GpuTimeZoneDBJni.cpp b/src/main/cpp/src/GpuTimeZoneDBJni.cpp index 32b67ecf35..e873eb99e3 100644 --- a/src/main/cpp/src/GpuTimeZoneDBJni.cpp +++ b/src/main/cpp/src/GpuTimeZoneDBJni.cpp @@ -157,7 +157,9 @@ Java_com_nvidia_spark_rapids_jni_GpuTimeZoneDB_convertOrcTimezones(JNIEnv* env, auto const writer_tz_info_tab = reinterpret_cast(writer_tz_info_table); auto const reader_tz_info_tab = reinterpret_cast(reader_tz_info_table); auto const writer_dst = parse_dst_rule(env, writer_dst_rule); - auto const reader_dst = parse_dst_rule(env, reader_dst_rule); + cudf::jni::check_java_exception(env); + auto const reader_dst = parse_dst_rule(env, reader_dst_rule); + cudf::jni::check_java_exception(env); return cudf::jni::ptr_as_jlong( spark_rapids_jni::convert_orc_writer_reader_timezones(*input, static_cast(base_offset_us), From fba5b8b0938a7fc79793082bb460ea671b166d1c Mon Sep 17 00:00:00 2001 From: Chong Gao Date: Fri, 17 Apr 2026 17:34:43 +0800 Subject: [PATCH 04/22] Make OrcTimezoneContext.close() idempotent Close() could be called more than once (e.g. explicit close followed by try-with-resources unwind), which would double-close the underlying writer/reader tables and trigger undefined behavior in the native layer. Guard with a "closed" flag so subsequent calls are no-ops. Signed-off-by: Chong Gao Made-with: Cursor --- src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java b/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java index b3cc97c656..08ddabb1d6 100644 --- a/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java +++ b/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java @@ -560,6 +560,7 @@ public static class OrcTimezoneContext implements AutoCloseable { private final int readerInitialOffset; private final int readerRawOffset; private final int[] readerDst; + private boolean closed; private OrcTimezoneContext(Table writerTzInfoTable, Table readerTzInfoTable, OrcTimezoneInfo writerTzInfo, OrcTimezoneInfo readerTzInfo) { @@ -575,6 +576,10 @@ private OrcTimezoneContext(Table writerTzInfoTable, Table readerTzInfoTable, @Override public void close() { + if (closed) { + return; + } + closed = true; if (writerTzInfoTable != null) { writerTzInfoTable.close(); } From 95467218c79c289279133836d35a0b6f206b5e11 Mon Sep 17 00:00:00 2001 From: Chong Gao Date: Fri, 17 Apr 2026 17:38:35 +0800 Subject: [PATCH 05/22] Drop unused offset_end parameter from transition index lookup get_transition_index only iterates by index off offset_begin; the matching end pointer is never dereferenced. Dropping it also lets convert_timestamp_between_timezones and convert_timezones_kernel stop computing/forwarding those pointers, reducing register pressure in the hot device code path. Signed-off-by: Chong Gao Made-with: Cursor --- src/main/cpp/src/timezones.cu | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/main/cpp/src/timezones.cu b/src/main/cpp/src/timezones.cu index c688d5b374..b55283389c 100644 --- a/src/main/cpp/src/timezones.cu +++ b/src/main/cpp/src/timezones.cu @@ -487,7 +487,6 @@ __device__ static int32_t get_transition_index(int64_t const* begin, int64_t const* end, int64_t time_ms, int32_t const* offset_begin, - int32_t const* offset_end, int32_t initial_offset, int32_t raw_offset, spark_rapids_jni::dst_rule const& rule) @@ -541,14 +540,12 @@ __device__ static cudf::timestamp_us convert_timestamp_between_timezones( int64_t const* writer_trans_begin, int64_t const* writer_trans_end, int32_t const* writer_offsets_begin, - int32_t const* writer_offsets_end, int32_t writer_initial_offset, int32_t writer_raw_offset, spark_rapids_jni::dst_rule const& writer_dst, int64_t const* reader_trans_begin, int64_t const* reader_trans_end, int32_t const* reader_offsets_begin, - int32_t const* reader_offsets_end, int32_t reader_initial_offset, int32_t reader_raw_offset, spark_rapids_jni::dst_rule const& reader_dst) @@ -572,7 +569,6 @@ __device__ static cudf::timestamp_us convert_timestamp_between_timezones( writer_trans_end, epoch_millis, writer_offsets_begin, - writer_offsets_end, writer_initial_offset, writer_raw_offset, writer_dst); @@ -582,7 +578,6 @@ __device__ static cudf::timestamp_us convert_timestamp_between_timezones( reader_trans_end, epoch_millis, reader_offsets_begin, - reader_offsets_end, reader_initial_offset, reader_raw_offset, reader_dst); @@ -594,7 +589,6 @@ __device__ static cudf::timestamp_us convert_timestamp_between_timezones( reader_trans_end, adjusted_milliseconds, reader_offsets_begin, - reader_offsets_end, reader_initial_offset, reader_raw_offset, reader_dst); @@ -672,21 +666,19 @@ __global__ void convert_timezones_kernel(cudf::timestamp_us const* __restrict__ int64_t const* wt_begin = writer_fits ? s_writer_trans : g_writer_trans; int64_t const* wt_end = wt_begin ? wt_begin + writer_trans_count : nullptr; int32_t const* wo_begin = writer_fits ? s_writer_offsets : g_writer_offsets; - int32_t const* wo_end = wo_begin ? wo_begin + writer_trans_count : nullptr; int64_t const* rt_begin = reader_fits ? s_reader_trans : g_reader_trans; int64_t const* rt_end = rt_begin ? rt_begin + reader_trans_count : nullptr; int32_t const* ro_begin = reader_fits ? s_reader_offsets : g_reader_offsets; - int32_t const* ro_end = ro_begin ? ro_begin + reader_trans_count : nullptr; // Handle null transition tables (fixed-offset timezones use nullptr) if (!g_writer_trans) { wt_begin = wt_end = nullptr; - wo_begin = wo_end = nullptr; + wo_begin = nullptr; } if (!g_reader_trans) { rt_begin = rt_end = nullptr; - ro_begin = ro_end = nullptr; + ro_begin = nullptr; } cudf::size_type idx = blockIdx.x * blockDim.x + threadIdx.x; @@ -697,14 +689,12 @@ __global__ void convert_timezones_kernel(cudf::timestamp_us const* __restrict__ wt_begin, wt_end, wo_begin, - wo_end, writer_initial_offset, writer_raw_offset, writer_dst, rt_begin, rt_end, ro_begin, - ro_end, reader_initial_offset, reader_raw_offset, reader_dst); From 7d08dc57e8bec087d0c1a69717ae6773b4936f40 Mon Sep 17 00:00:00 2001 From: Chong Gao Date: Fri, 17 Apr 2026 17:39:33 +0800 Subject: [PATCH 06/22] Remove unreachable equality branch in get_transition_index thrust::upper_bound returns the first element strictly greater than the needle, so the subsequent `*iter == time_ms` check could never be true. Remove the dead branch and add a comment explaining the upper_bound semantics so the index-1 math is obvious. Signed-off-by: Chong Gao Made-with: Cursor --- src/main/cpp/src/timezones.cu | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/cpp/src/timezones.cu b/src/main/cpp/src/timezones.cu index b55283389c..fb88df8a1a 100644 --- a/src/main/cpp/src/timezones.cu +++ b/src/main/cpp/src/timezones.cu @@ -496,6 +496,8 @@ __device__ static int32_t get_transition_index(int64_t const* begin, return compute_dst_offset(time_ms, raw_offset, rule); } + // upper_bound returns the first element strictly greater than time_ms, so + // *iter > time_ms and the index we want is iter - 1. auto const iter = thrust::upper_bound(thrust::seq, begin, end, time_ms); if (iter == end) { // Beyond the transition table -- use DST rule for future dates @@ -503,8 +505,6 @@ __device__ static int32_t get_transition_index(int64_t const* begin, } int32_t index = static_cast(cuda::std::distance(begin, iter)); - if (*iter == time_ms) { return offset_begin[index]; } - if (index == 0) { // Before the first recorded transition, java.util.TimeZone uses the // historical offset in effect before that transition, not the future rule. From 53fc9b65b5da893f240c9008b75cb188a2f8e344 Mon Sep 17 00:00:00 2001 From: Chong Gao Date: Fri, 17 Apr 2026 17:41:24 +0800 Subject: [PATCH 07/22] Explain why fillDstRuleFromTransitionRule hard-codes DOW_GE_DOM_MODE ZoneOffsetTransitionRule can express both "day-of-week on or after day" (positive indicator, DOW_GE_DOM_MODE=2) and "day-of-week on or before day" (negative indicator, DOW_LE_DOM_MODE=3). We reject the latter in the precondition, so mode=2 is always safe. Document this invariant so future readers don't wonder why the mode isn't derived from the transition rule. Signed-off-by: Chong Gao Made-with: Cursor --- .../java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java b/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java index 6898972114..fc1a255295 100644 --- a/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java +++ b/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java @@ -155,6 +155,10 @@ private static DstRule extractDstRuleFromZoneRules(String timezoneId, TimeZone t private static void fillDstRuleFromTransitionRule(String timezoneId, DstRule rule, ZoneOffsetTransitionRule transitionRule, boolean isStartRule) { + // We only accept rules shaped as "first on or after ", + // i.e. ZoneRules' positive-day-indicator form. A negative indicator would mean + // "last on or before day" (DOW_LE_DOM_MODE, mode=3); we reject those + // here so that downstream code can assume DOW_GE_DOM_MODE (mode=2) unconditionally. if (transitionRule.getDayOfWeek() == null || transitionRule.getDayOfMonthIndicator() <= 0) { throw new IllegalStateException("Unsupported ORC DST transition rule shape for timezone: " + @@ -166,7 +170,8 @@ private static void fillDstRuleFromTransitionRule(String timezoneId, DstRule rul int dayOfWeek = toCalendarDayOfWeek(transitionRule.getDayOfWeek().getValue()); int time = getTransitionRuleTimeMillis(transitionRule); int timeMode = getTransitionRuleTimeMode(transitionRule); - int mode = 2; // DOW_GE_DOM_MODE + // SimpleTimeZone mode constant: DOW_GE_DOM_MODE. Guaranteed by the precondition above. + int mode = 2; if (isStartRule) { rule.startMonth = month; From 15e2479796d1a201a01624c1ce422aa3425d3809 Mon Sep 17 00:00:00 2001 From: Chong Gao Date: Fri, 17 Apr 2026 17:47:43 +0800 Subject: [PATCH 08/22] Add 2060 as a near-future DST rule verification anchor DST_RULE_VALIDATION_YEARS previously only checked 2400 and 9997, both well past any IANA explicit transition entry. That meant a zone whose CPU-side TimeZone.getOffset() still followed explicit transitions into the 2040s-2050s could drift from our derived recurring rule without the verification catching it. Add 2060 so we notice mismatches within a typical application lifetime; keep 2400/9997 to continue exercising the recurring-rule fallback. Signed-off-by: Chong Gao Made-with: Cursor --- .../java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java b/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java index fc1a255295..9244cc5148 100644 --- a/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java +++ b/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java @@ -80,7 +80,11 @@ static class DstRule { int endMode; } - private static final int[] DST_RULE_VALIDATION_YEARS = {2400, 9997}; + // Reference years used to cross-check CPU vs. GPU DST offset computation. + // We include a near-future anchor (2060) to catch divergence within the + // typical application lifetime, plus two far-future anchors to exercise the + // recurring-rule fallback path well past any historical transition entry. + private static final int[] DST_RULE_VALIDATION_YEARS = {2060, 2400, 9997}; private static final long MIN_SUPPORTED_ORC_UTC_MILLIS = utcMillisForDate(1, 0, 1); private static final long HISTORICAL_TRANSITION_SCAN_STEP_MILLIS = 24L * 3600_000L; From f53bd7b08283f9b0339260f95430f1811312e816 Mon Sep 17 00:00:00 2001 From: Chong Gao Date: Fri, 17 Apr 2026 17:49:41 +0800 Subject: [PATCH 09/22] Document calendar mismatch at MIN_SUPPORTED_ORC_UTC_MILLIS MIN_SUPPORTED_ORC_UTC_MILLIS is computed via java.time.LocalDate (proleptic Gregorian), whereas TimeZone.getOffset(long) uses a hybrid Julian/Gregorian calendar internally. The distinction is academic here since the offset lookup is instant-based and no zone has DST in year 0001, but the subtlety is non-obvious; add a comment so future readers don't have to re-derive why this is safe. Signed-off-by: Chong Gao Made-with: Cursor --- .../java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java b/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java index 9244cc5148..1c95c226b9 100644 --- a/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java +++ b/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java @@ -85,6 +85,14 @@ static class DstRule { // typical application lifetime, plus two far-future anchors to exercise the // recurring-rule fallback path well past any historical transition entry. private static final int[] DST_RULE_VALIDATION_YEARS = {2060, 2400, 9997}; + // Lower bound of the range ORC supports (year 0001-01-01 UTC). Computed via + // java.time.LocalDate, which uses the proleptic Gregorian calendar, whereas + // java.util.TimeZone.getOffset(long) internally uses a hybrid Julian/Gregorian + // calendar with the 1582 cutover for date-field interpretations. In practice + // this difference does not affect offset lookup (which is purely instant-based + // for ZoneInfo), and zones with DST in year 0001 do not exist, so the two + // calendars agree on the offset at this instant. Kept as a single anchor so + // the GPU side matches whatever TimeZone.getOffset returns here. private static final long MIN_SUPPORTED_ORC_UTC_MILLIS = utcMillisForDate(1, 0, 1); private static final long HISTORICAL_TRANSITION_SCAN_STEP_MILLIS = 24L * 3600_000L; From 526139ef00878990bcdedf7623389f6e0540c55c Mon Sep 17 00:00:00 2001 From: Chong Gao Date: Fri, 17 Apr 2026 17:53:22 +0800 Subject: [PATCH 10/22] Avoid redundant ZoneId lookup in extractDstRuleFromZoneRules buildRuntimeOrcTimezoneInfo already resolves ZoneId and ZoneRules for the incoming timezone id, but extractDstRuleFromZoneRules re-calls GpuTimeZoneDB.getZoneId(timezoneId).getRules() for the same zone. Pass the ZoneRules through extractDstRule so each buildRuntimeOrcTimezoneInfo call resolves the zone at most once. Signed-off-by: Chong Gao Made-with: Cursor --- .../com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java b/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java index 1c95c226b9..b3e97d5f50 100644 --- a/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java +++ b/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java @@ -100,7 +100,7 @@ static class DstRule { * Extract DST rule by probing getOffset() or from ZoneRules transition rules. * Returns null if the timezone has no DST. */ - static DstRule extractDstRule(String timezoneId, TimeZone tz) { + static DstRule extractDstRule(String timezoneId, TimeZone tz, ZoneRules rules) { if (!tz.useDaylightTime()) { return null; } @@ -109,15 +109,15 @@ static DstRule extractDstRule(String timezoneId, TimeZone tz) { return dstRule; } - dstRule = extractDstRuleFromZoneRules(timezoneId, tz); + dstRule = extractDstRuleFromZoneRules(timezoneId, tz, rules); if (dstRule != null) { return dstRule; } throw new IllegalStateException("Failed to extract ORC DST rule for timezone: " + timezoneId); } - private static DstRule extractDstRuleFromZoneRules(String timezoneId, TimeZone tz) { - ZoneRules rules = GpuTimeZoneDB.getZoneId(timezoneId).getRules(); + private static DstRule extractDstRuleFromZoneRules(String timezoneId, TimeZone tz, + ZoneRules rules) { List transitionRules = rules.getTransitionRules(); if (transitionRules.isEmpty()) { return null; @@ -524,7 +524,7 @@ private static OrcTimezoneInfo buildRuntimeOrcTimezoneInfo(String timezoneId) { if (rules.isFixedOffset()) { return new OrcTimezoneInfo(initialOffset, tz.getRawOffset(), null, null, null); } - DstRule dstRule = extractDstRule(timezoneId, tz); + DstRule dstRule = extractDstRule(timezoneId, tz, rules); List transitionList = rules.getTransitions(); HistoricalTransitions historicalTransitions = buildHistoricalTransitions(tz, transitionList); From c121a14c45782eefdb0f2ebf98f3f342a69307f8 Mon Sep 17 00:00:00 2001 From: Chong Gao Date: Fri, 17 Apr 2026 17:55:19 +0800 Subject: [PATCH 11/22] Document JVM tzdata dependency on OrcTimezoneInfo Timezone metadata is now generated at runtime from java.util.TimeZone and java.time.zone.ZoneRules, so results depend on whichever IANA tzdata ships with the running JVM. Previously we shipped a frozen OpenJDK-8 snapshot, so results were identical across environments. Add a Javadoc note so users who see cross-environment discrepancies know to check their JVM's tzdata version first. Signed-off-by: Chong Gao Made-with: Cursor --- .../java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java b/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java index b3e97d5f50..da212df9c2 100644 --- a/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java +++ b/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java @@ -18,6 +18,14 @@ * Historical transitions come from ZoneRules, while offsets before the first transition and * future recurring DST behavior are validated against java.util.TimeZone so ORC rebasing matches * SerializationUtils.convertBetweenTimezones semantics without relying on non-public ZoneInfo APIs. + * + *

Runtime dependency: because the metadata is generated on the fly from + * {@link java.util.TimeZone}/{@link java.time.zone.ZoneRules}, the exact transition table and + * recurring DST rule are determined by the JVM's bundled IANA {@code tzdata}. Different JDK + * distributions or {@code tzdata} versions may produce slightly different historical + * transitions or future-year DST offsets for the same zone id. This is strictly more correct + * than the previous frozen OpenJDK-8 snapshot, but users debugging cross-environment + * differences should first check the JVM's {@code tzdata} version. */ class OrcTimezoneInfo { public OrcTimezoneInfo( From fae3c28849182837711c009193492e7aeebbd214 Mon Sep 17 00:00:00 2001 From: Chong Gao Date: Fri, 17 Apr 2026 18:42:37 +0800 Subject: [PATCH 12/22] Fix Copyright Made-with: Cursor --- src/main/cpp/src/GpuTimeZoneDBJni.cpp | 2 +- src/main/cpp/src/timezones.hpp | 2 +- .../nvidia/spark/rapids/jni/GpuTimeZoneDB.java | 2 +- .../nvidia/spark/rapids/jni/OrcTimezoneInfo.java | 16 ++++++++++++++++ .../spark/rapids/jni/GpuTimeZoneDBTest.java | 2 +- 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/main/cpp/src/GpuTimeZoneDBJni.cpp b/src/main/cpp/src/GpuTimeZoneDBJni.cpp index e873eb99e3..3bef8f5105 100644 --- a/src/main/cpp/src/GpuTimeZoneDBJni.cpp +++ b/src/main/cpp/src/GpuTimeZoneDBJni.cpp @@ -1,4 +1,4 @@ -/* Copyright (c) 2023-2025, NVIDIA CORPORATION. +/* Copyright (c) 2023-2026, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/cpp/src/timezones.hpp b/src/main/cpp/src/timezones.hpp index 5ac1057a22..5f77bab1a4 100644 --- a/src/main/cpp/src/timezones.hpp +++ b/src/main/cpp/src/timezones.hpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023-2025, NVIDIA CORPORATION. + * Copyright (c) 2023-2026, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java b/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java index 08ddabb1d6..05be29d148 100644 --- a/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java +++ b/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java @@ -1,5 +1,5 @@ /* -* Copyright (c) 2023-2025, NVIDIA CORPORATION. +* Copyright (c) 2023-2026, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java b/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java index da212df9c2..515fe39c36 100644 --- a/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java +++ b/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java @@ -1,3 +1,19 @@ +/* + * Copyright (c) 2025-2026, NVIDIA CORPORATION. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.nvidia.spark.rapids.jni; import java.time.DateTimeException; diff --git a/src/test/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDBTest.java b/src/test/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDBTest.java index ec9827cece..41a89d0014 100644 --- a/src/test/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDBTest.java +++ b/src/test/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDBTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, NVIDIA CORPORATION. + * Copyright (c) 2025-2026, NVIDIA CORPORATION. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. From 0f515d3ac750717ea5ad5ea7c474aef2e1f5ac6a Mon Sep 17 00:00:00 2001 From: Chong Gao Date: Fri, 17 Apr 2026 18:53:38 +0800 Subject: [PATCH 13/22] Apply clang-format Made-with: Cursor --- src/main/cpp/src/timezones.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/cpp/src/timezones.hpp b/src/main/cpp/src/timezones.hpp index 5f77bab1a4..4958ebc892 100644 --- a/src/main/cpp/src/timezones.hpp +++ b/src/main/cpp/src/timezones.hpp @@ -123,7 +123,7 @@ struct dst_rule { int32_t start_dow; // day-of-week 1=Sun..7=Sat, 0 for DOM_MODE int32_t start_time; // ms within day int32_t start_time_mode; - int32_t start_mode; // 0=DOM, 1=DOW_IN_MONTH, 2=DOW_GE_DOM, 3=DOW_LE_DOM + int32_t start_mode; // 0=DOM, 1=DOW_IN_MONTH, 2=DOW_GE_DOM, 3=DOW_LE_DOM int32_t end_month; int32_t end_day; int32_t end_dow; From 2e1b9d426c6a1954e3cbb3487849db9d4bdfe566 Mon Sep 17 00:00:00 2001 From: Chong Gao Date: Thu, 30 Apr 2026 14:33:15 +0800 Subject: [PATCH 14/22] Fix comments Signed-off-by: Chong Gao --- .../spark/rapids/jni/GpuTimeZoneDB.java | 7 +-- .../spark/rapids/jni/OrcTimezoneInfo.java | 16 +++++-- .../spark/rapids/jni/GpuTimeZoneDBTest.java | 46 +++++++++++++++++-- 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java b/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java index 49da734f79..9c1fa576d2 100644 --- a/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java +++ b/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java @@ -598,11 +598,12 @@ public void close() { */ public static OrcTimezoneContext buildOrcTimezoneContext( String writerTimezone, String readerTimezone) { - OrcTimezoneInfo writerTzInfo = OrcTimezoneInfo.get(writerTimezone); - OrcTimezoneInfo readerTzInfo = OrcTimezoneInfo.get(readerTimezone); - Table writerTable = getTableForUtilTZ(writerTzInfo); + Table writerTable = null; Table readerTable = null; try { + OrcTimezoneInfo writerTzInfo = OrcTimezoneInfo.get(writerTimezone); + OrcTimezoneInfo readerTzInfo = OrcTimezoneInfo.get(readerTimezone); + writerTable = getTableForUtilTZ(writerTzInfo); readerTable = getTableForUtilTZ(readerTzInfo); return new OrcTimezoneContext(writerTable, readerTable, writerTzInfo, readerTzInfo); } catch (Exception e) { diff --git a/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java b/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java index 515fe39c36..4e95375035 100644 --- a/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java +++ b/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java @@ -17,6 +17,7 @@ package com.nvidia.spark.rapids.jni; import java.time.DateTimeException; +import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; import java.time.zone.ZoneOffsetTransition; @@ -283,8 +284,13 @@ private static DstRule extractDstRuleByProbing(TimeZone tz, int refYear) { // Found a transition; narrow down to exact millisecond with binary search long exactMs = binarySearchTransition(tz, ms - step, ms); if (curOffset > prevOffset) { + // More than one DST-on transition in the same year means this year + // doesn't fit a SimpleTimeZone-style two-transition rule; let the + // caller fall back to extractDstRuleFromZoneRules. + if (dstOnTransition >= 0) return null; dstOnTransition = exactMs; } else { + if (dstOffTransition >= 0) return null; dstOffTransition = exactMs; } prevOffset = curOffset; @@ -542,12 +548,16 @@ private static OrcTimezoneInfo buildRuntimeOrcTimezoneInfo(String timezoneId) { throw new IllegalArgumentException("Timezone ID not found: " + timezoneId, e); } - TimeZone tz = TimeZone.getTimeZone(timezoneId); ZoneRules rules = zoneId.getRules(); - int initialOffset = getInitialOffset(tz); if (rules.isFixedOffset()) { - return new OrcTimezoneInfo(initialOffset, tz.getRawOffset(), null, null, null); + // IDs like "+05:30" are valid ZoneIds but TimeZone.getTimeZone() silently + // maps them to GMT (offset 0). Derive the offset from ZoneRules instead so + // the GPU path doesn't treat them as UTC. + int fixedOffsetMs = rules.getOffset(Instant.EPOCH).getTotalSeconds() * 1000; + return new OrcTimezoneInfo(fixedOffsetMs, fixedOffsetMs, null, null, null); } + TimeZone tz = TimeZone.getTimeZone(timezoneId); + int initialOffset = getInitialOffset(tz); DstRule dstRule = extractDstRule(timezoneId, tz, rules); List transitionList = rules.getTransitions(); diff --git a/src/test/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDBTest.java b/src/test/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDBTest.java index 41a89d0014..b103f448fd 100644 --- a/src/test/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDBTest.java +++ b/src/test/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDBTest.java @@ -22,12 +22,15 @@ import static ai.rapids.cudf.AssertUtils.assertColumnsAreEqual; import static org.junit.jupiter.api.Assumptions.assumeTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertFalse; +import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneOffset; +import java.time.zone.ZoneRules; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; @@ -57,20 +60,41 @@ private static ColumnVector convertOrcTimezonesOnCPU( String writeTzId, String readerTzId) { long[] results = new long[microseconds.length]; - TimeZone writeTz = TimeZone.getTimeZone(writeTzId); - TimeZone readerTz = TimeZone.getTimeZone(readerTzId); + OffsetLookup writeLookup = OffsetLookup.of(writeTzId); + OffsetLookup readerLookup = OffsetLookup.of(readerTzId); for (int i = 0; i < microseconds.length; ++i) { long millis = microseconds[i] / microsPerMillis; - long writerOffset = writeTz.getOffset(millis); - long readerOffset = readerTz.getOffset(millis); + long writerOffset = writeLookup.offsetMillis(millis); + long readerOffset = readerLookup.offsetMillis(millis); long adjustedMillis = millis + writerOffset - readerOffset; - long adjustedReader = readerTz.getOffset(adjustedMillis); + long adjustedReader = readerLookup.offsetMillis(adjustedMillis); long finalDiffs = writerOffset - adjustedReader; results[i] = (millis + finalDiffs) * microsPerMillis + (microseconds[i] % microsPerMillis); } return ColumnVector.timestampMicroSecondsFromLongs(results); } + /** + * Resolves UTC ms to its offset in ms. For fixed-offset IDs that + * {@link TimeZone#getTimeZone(String)} silently maps to GMT (e.g. "+05:30"), + * uses {@link ZoneRules} via the project's own {@link GpuTimeZoneDB#getZoneId} + * parser so the oracle doesn't share the same blind spot as the buggy version + * of the production path. + */ + private interface OffsetLookup { + long offsetMillis(long utcMillis); + + static OffsetLookup of(String tzId) { + ZoneRules rules = GpuTimeZoneDB.getZoneId(tzId).getRules(); + if (rules.isFixedOffset()) { + long offsetMs = rules.getOffset(Instant.EPOCH).getTotalSeconds() * 1000L; + return ms -> offsetMs; + } + TimeZone tz = TimeZone.getTimeZone(tzId); + return tz::getOffset; + } + } + private static long microsUtc(LocalDateTime timestamp) { return timestamp.toEpochSecond(ZoneOffset.UTC) * TimeUnit.SECONDS.toMicros(1); } @@ -395,6 +419,18 @@ void testConvertOrcTimezonesDynamicFixedOffset() { } } } + + // Direct assertion that "+05:30" really shifts by 5h30m relative to UTC. + // Independent of convertOrcTimezonesOnCPU so the bug can't sneak back via a + // weakened oracle. + long[] zeroEpoch = {0L}; + try (ColumnVector input = ColumnVector.timestampMicroSecondsFromLongs(zeroEpoch); + ColumnVector actual = GpuTimeZoneDB.convertOrcTimezones(input, 0L, "+05:30", "UTC"); + HostColumnVector hcv = actual.copyToHost()) { + // Writer wall-time 1970-01-01T00:00:00 in +05:30 corresponds to UTC -5h30m. + assertEquals(-5L * 3600L * 1_000_000L - 30L * 60L * 1_000_000L, hcv.getLong(0), + "+05:30 → UTC must shift the writer wall-time by 5h30m"); + } } /** From 66305a7c8e2955adf72ce10d46b9b758cb71fc59 Mon Sep 17 00:00:00 2001 From: Chong Gao Date: Fri, 1 May 2026 01:56:58 +0800 Subject: [PATCH 15/22] Fix Signed-off-by: Chong Gao --- .../java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java | 7 +++++++ .../com/nvidia/spark/rapids/jni/GpuTimeZoneDBTest.java | 9 ++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java b/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java index 9c1fa576d2..cab3e5b7b5 100644 --- a/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java +++ b/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java @@ -606,6 +606,13 @@ public static OrcTimezoneContext buildOrcTimezoneContext( writerTable = getTableForUtilTZ(writerTzInfo); readerTable = getTableForUtilTZ(readerTzInfo); return new OrcTimezoneContext(writerTable, readerTable, writerTzInfo, readerTzInfo); + } catch (RuntimeException e) { + // Preserve typed signals from the build path (e.g. IllegalArgumentException + // for invalid zone IDs) so callers can distinguish bad input from + // internal failures. + if (writerTable != null) writerTable.close(); + if (readerTable != null) readerTable.close(); + throw e; } catch (Exception e) { if (writerTable != null) writerTable.close(); if (readerTable != null) readerTable.close(); diff --git a/src/test/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDBTest.java b/src/test/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDBTest.java index b103f448fd..dd400cad14 100644 --- a/src/test/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDBTest.java +++ b/src/test/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDBTest.java @@ -427,9 +427,12 @@ void testConvertOrcTimezonesDynamicFixedOffset() { try (ColumnVector input = ColumnVector.timestampMicroSecondsFromLongs(zeroEpoch); ColumnVector actual = GpuTimeZoneDB.convertOrcTimezones(input, 0L, "+05:30", "UTC"); HostColumnVector hcv = actual.copyToHost()) { - // Writer wall-time 1970-01-01T00:00:00 in +05:30 corresponds to UTC -5h30m. - assertEquals(-5L * 3600L * 1_000_000L - 30L * 60L * 1_000_000L, hcv.getLong(0), - "+05:30 → UTC must shift the writer wall-time by 5h30m"); + // ORC's convertBetweenTimezones adds (writerOffset - readerOffset), so + // converting from "+05:30" to UTC shifts the input by +5h30m, not -5h30m. + // The earlier wrapping of "+05:30" to GMT would have produced 0; this + // confirms the runtime metadata path uses the synthesized +05:30 offset. + assertEquals(5L * 3600L * 1_000_000L + 30L * 60L * 1_000_000L, hcv.getLong(0), + "+05:30 → UTC must shift the writer wall-time by +5h30m"); } } From 75ff8eea4eea72e8c0559940f3582e983fb718a4 Mon Sep 17 00:00:00 2001 From: Chong Gao Date: Wed, 6 May 2026 11:39:18 +0800 Subject: [PATCH 16/22] Null out ORC timezone tables after close Per review: easier debugging if OrcTimezoneContext is accidentally used after close(). Signed-off-by: Chong Gao --- .../java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java b/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java index cab3e5b7b5..c1fb25c1d1 100644 --- a/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java +++ b/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java @@ -555,8 +555,8 @@ static List getOrcSupportedTimezones() { * reused across multiple timestamp columns in the same table. */ public static class OrcTimezoneContext implements AutoCloseable { - private final Table writerTzInfoTable; - private final Table readerTzInfoTable; + private Table writerTzInfoTable; + private Table readerTzInfoTable; private final int writerInitialOffset; private final int writerRawOffset; private final int[] writerDst; @@ -585,9 +585,11 @@ public void close() { closed = true; if (writerTzInfoTable != null) { writerTzInfoTable.close(); + writerTzInfoTable = null; } if (readerTzInfoTable != null) { readerTzInfoTable.close(); + readerTzInfoTable = null; } } } From 43c5ac2f26bfb57c213c5c8920fe9e83b6cd0f55 Mon Sep 17 00:00:00 2001 From: Chong Gao Date: Wed, 6 May 2026 11:39:42 +0800 Subject: [PATCH 17/22] Convert DST rule mode constants to enum Per review: an enum carries semantic intent better than a set of constexpr ints. Signed-off-by: Chong Gao --- src/main/cpp/src/timezones.cu | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/cpp/src/timezones.cu b/src/main/cpp/src/timezones.cu index fb88df8a1a..c90a7b0bdb 100644 --- a/src/main/cpp/src/timezones.cu +++ b/src/main/cpp/src/timezones.cu @@ -284,10 +284,12 @@ __device__ static int64_t days_from_epoch_to_year(int32_t year) } // DST rule mode constants (same as SimpleTimeZone) -constexpr int32_t DOM_MODE = 0; -constexpr int32_t DOW_IN_MONTH_MODE = 1; -constexpr int32_t DOW_GE_DOM_MODE = 2; -constexpr int32_t DOW_LE_DOM_MODE = 3; +enum dst_rule_mode : int32_t { + DOM_MODE = 0, + DOW_IN_MONTH_MODE = 1, + DOW_GE_DOM_MODE = 2, + DOW_LE_DOM_MODE = 3 +}; // Time mode constants constexpr int32_t WALL_TIME = 0; From ad1b6a4c24515c2b6e80e4f49f421a7aac9f23ba Mon Sep 17 00:00:00 2001 From: Chong Gao Date: Wed, 6 May 2026 11:40:12 +0800 Subject: [PATCH 18/22] Convert DST time mode constants to enum Per review: an enum carries semantic intent better than a set of constexpr ints. Signed-off-by: Chong Gao --- src/main/cpp/src/timezones.cu | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/cpp/src/timezones.cu b/src/main/cpp/src/timezones.cu index c90a7b0bdb..2428aa438b 100644 --- a/src/main/cpp/src/timezones.cu +++ b/src/main/cpp/src/timezones.cu @@ -292,9 +292,7 @@ enum dst_rule_mode : int32_t { }; // Time mode constants -constexpr int32_t WALL_TIME = 0; -constexpr int32_t STANDARD_TIME = 1; -constexpr int32_t UTC_TIME = 2; +enum dst_time_mode : int32_t { WALL_TIME = 0, STANDARD_TIME = 1, UTC_TIME = 2 }; /** * @brief Compute the day-of-month when a DST rule triggers for the given year and month. From 7557f8f7c8cf3e952fa932bd50573a185a519d3d Mon Sep 17 00:00:00 2001 From: Chong Gao Date: Wed, 6 May 2026 11:40:34 +0800 Subject: [PATCH 19/22] Document buildRuntimeOrcTimezoneInfo as expensive Per review: callers should know that the historical scan + DST-rule probing path is costly, and that they should always go through the cached get(...) wrapper. Signed-off-by: Chong Gao --- .../java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java b/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java index 4e95375035..c84fd397c4 100644 --- a/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java +++ b/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java @@ -539,6 +539,12 @@ public static OrcTimezoneInfo get(String timezoneId) { * Build ORC timezone metadata from public java.time/java.util APIs. Invalid IDs use the same * validation as {@link GpuTimeZoneDB#getZoneId(String)} and fail with * {@link IllegalArgumentException} (no silent fallback to GMT). + * + *

Cost: this is expensive — it scans every historical {@link ZoneOffsetTransition} + * from year 1 onward, probes {@link TimeZone#getOffset(long)} hourly to extract the recurring + * DST rule, and then verifies the rule across multiple reference years. Results are cached in + * {@link #RUNTIME_TIMEZONE_INFOS} (see {@link #get(String)}), so callers should always go + * through {@code get(...)} rather than invoking this directly. */ private static OrcTimezoneInfo buildRuntimeOrcTimezoneInfo(String timezoneId) { final ZoneId zoneId; From d8427e62975ad197e88abc60c0c6863efb4f6c87 Mon Sep 17 00:00:00 2001 From: Chong Gao Date: Wed, 6 May 2026 11:48:29 +0800 Subject: [PATCH 20/22] Record measured cold-build cost in OrcTimezoneInfo docstring ~4 ms typical / ~12 ms worst-case (ART) on an Intel i7-10700K with OpenJDK 17, measured across all 626 JVM-known zones. Signed-off-by: Chong Gao --- .../com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java b/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java index c84fd397c4..547d674f9b 100644 --- a/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java +++ b/src/main/java/com/nvidia/spark/rapids/jni/OrcTimezoneInfo.java @@ -540,11 +540,13 @@ public static OrcTimezoneInfo get(String timezoneId) { * validation as {@link GpuTimeZoneDB#getZoneId(String)} and fail with * {@link IllegalArgumentException} (no silent fallback to GMT). * - *

Cost: this is expensive — it scans every historical {@link ZoneOffsetTransition} + *

Cost: this is non-trivial — it scans every historical {@link ZoneOffsetTransition} * from year 1 onward, probes {@link TimeZone#getOffset(long)} hourly to extract the recurring - * DST rule, and then verifies the rule across multiple reference years. Results are cached in - * {@link #RUNTIME_TIMEZONE_INFOS} (see {@link #get(String)}), so callers should always go - * through {@code get(...)} rather than invoking this directly. + * DST rule, and then verifies the rule across multiple reference years. Measured on an Intel + * i7-10700K with OpenJDK 17, a cold build takes ~4 ms typical and up to ~12 ms for the worst + * zones (e.g. {@code ART}). Results are cached in {@link #RUNTIME_TIMEZONE_INFOS} (see + * {@link #get(String)}), so callers should always go through {@code get(...)} rather than + * invoking this directly. */ private static OrcTimezoneInfo buildRuntimeOrcTimezoneInfo(String timezoneId) { final ZoneId zoneId; From 631c7656c1b4b5ae9a580d9d3490a2846b4e38d5 Mon Sep 17 00:00:00 2001 From: Chong Gao Date: Wed, 6 May 2026 11:57:09 +0800 Subject: [PATCH 21/22] Use cuda::std::array for DAYS_BEFORE_MONTH Per review: cuda::std::array carries length and bounds-friendly access better than a C-style array. __device__ storage is still needed because days_in_month/days_before_month index this with non-compile-time values. Signed-off-by: Chong Gao --- src/main/cpp/src/timezones.cu | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/cpp/src/timezones.cu b/src/main/cpp/src/timezones.cu index 2428aa438b..ba7c6e000b 100644 --- a/src/main/cpp/src/timezones.cu +++ b/src/main/cpp/src/timezones.cu @@ -32,6 +32,7 @@ #include #include +#include #include #include @@ -252,7 +253,9 @@ constexpr int32_t MS_PER_HOUR = 60 * MS_PER_MINUTE; constexpr int64_t MS_PER_DAY = 24LL * MS_PER_HOUR; // Cumulative days before each month (non-leap). Index 0 = Jan. -__device__ constexpr int32_t DAYS_BEFORE_MONTH[] = { +// __device__ storage is required because days_in_month / days_before_month index +// this with non-compile-time values, which addresses elements at runtime. +__device__ constexpr cuda::std::array DAYS_BEFORE_MONTH = { 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334}; __device__ static bool is_leap_year(int32_t year) From 5b5c09d7ee0934252a5cc8623155428d91a6d673 Mon Sep 17 00:00:00 2001 From: Chong Gao Date: Thu, 7 May 2026 14:48:28 +0800 Subject: [PATCH 22/22] Add back convertOrcTimezones Signed-off-by: Chong Gao --- .../com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java b/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java index c1fb25c1d1..24e4876795 100644 --- a/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java +++ b/src/main/java/com/nvidia/spark/rapids/jni/GpuTimeZoneDB.java @@ -657,6 +657,18 @@ public static ColumnVector convertOrcTimezones( } } + /** + * Backwards-compatible overload preserving the pre-DST signature. Equivalent + * to calling {@link #convertOrcTimezones(ColumnVector, long, String, String)} + * with {@code baseOffsetUs = 0L}. + */ + public static ColumnVector convertOrcTimezones( + ColumnVector input, + String writerTimezone, + String readerTimezone) { + return convertOrcTimezones(input, 0L, writerTimezone, readerTimezone); + } + /** * Convert a DstRule to a 13-element int array for JNI, or null if no DST. * Order: dstSavings, startMonth, startDay, startDayOfWeek, startTime,