Uh oh!
There was an error while loading. Please reload this page.
[fix](arrow) encode DATE in the proleptic Gregorian calendar at format boundaries - #67449
Draft
morningman wants to merge 3 commits into
Draft
[fix](arrow) encode DATE in the proleptic Gregorian calendar at format boundaries#67449morningman wants to merge 3 commits into
morningman wants to merge 3 commits into
Conversation
morningman
requested review from
924060929, englefly, morrySnow and starocean999
as code ownersSeptember 2, 2026 12:16
hello-stephen
commented
Sep 2, 2026
Contributor
Thank you for your contribution to Apache Doris. Please clearly describe your PR:
|
…t boundaries ### What problem does this PR solve? Issue Number: closeapache#67366 Problem Summary: Doris follows MySQL's calendar, in which year 0 is not a leap year: 0000-02-29 does not exist and calc_daynr() therefore numbers 0000-01-01 .. 0000-02-28 one day ahead of the proleptic Gregorian calendar. Arrow date32, Parquet DATE, ORC DATE and the Iceberg spec all define their day ordinal in the proleptic Gregorian calendar, where year 0 IS a leap year, and the two numberings coincide only from 0000-03-01 onwards. Every format boundary subtracted or added the epoch day number directly, so all 59 dates in 0000-01-01 .. 0000-02-28 were shipped one day late: 0000-01-01 left Doris as -719527, which Arrow renders as 0000-01-02, while the MySQL protocol -- which carries year/month/day verbatim and never needs a calendar -- reported 0000-01-01 for the same value. Add daynr_to_epoch_days()/epoch_days_to_daynr() and route the boundaries through them: Arrow date32/date64 read and write (which Parquet export shares), the Parquet DATE reader, and the ORC DATE reader and writer. Both helpers compile to branchless code, so the write path costs two extra instructions and the read path is absorbed by the following get_date_from_daynr(). The ORC reader now passes the file's day value straight through instead of laundering it through date_day_offset_dict, which also removes a silent fallback that decoded any out-of-dictionary value as 1900-01-01. The Arrow date32 reader now checks the result of get_date_from_daynr() instead of discarding it, and 0000-02-29 -- which exists in the proleptic Gregorian calendar but not in Doris -- is rejected rather than silently decoding as 0000-02-28. The Iceberg partition transforms had a second, independent defect: they were derived from datetime_diff(), which rounds towards zero, while Iceberg floors (DateTimeUtil.convertDays/convertMicros evaluate one unit later for a negative input and then subtract one). Every pre-1970 value that was not exactly on a unit boundary got the wrong partition -- 1969-12-31 23:59:59 landed in the same day and hour partition as 1970-01-01 00:00:00, and year()/month() reported 0 and -6 for 1969-06-15 where Iceberg requires -1 and -7. Rewrite year, month, day, hour and bucket for both DATE and TIMESTAMP, and make human_hour() floor as well so a negative hour ordinal renders as 1969-12-31-23 rather than 1970-01-01--1. Verified against cctz over all 3,652,424 representable Doris dates, and against the Apache Iceberg reference implementation (iceberg-api 1.10.1, after reproducing the spec's own Appendix B test vector 2017-11-16 -> -653330422). A new regression suite writes identical boundary rows from Doris and from Spark into two identically partitioned Iceberg tables and compares the resulting partition metadata, so the transforms are checked against the reference implementation end to end rather than against Doris's own expectations. ### Release note DATE values in 0000-01-01 .. 0000-02-28 now cross every file and wire boundary with the proleptic Gregorian day ordinal the format specifications require: - Arrow date32/date64, Parquet DATE and ORC DATE written by Doris encode 0000-01-01 as -719528 instead of -719527. Files written by an older Doris are read one day later than before (0000-01-01 comes back as 0000-01-02), except for an old-encoded 0000-02-28 (ordinal -719469), which is the proleptic-only 0000-02-29 and has no Doris DATE: an ORC scan fails, a Parquet scan fails in strict mode and returns NULL otherwise, and Arrow input fails. Rewrite such files from the source table to repair them. - Arrow date32 input (stream load `format=arrow`, the ADBC catalog, the remote-Doris catalog, the Paimon native reader, Python UDTFs) now reports an out-of-range day instead of silently storing a zero date. - Federating an older and a newer Doris through the remote-Doris catalog shifts year-zero dates by one day in the mixed-version window, and fails on an old-encoded 0000-02-28. - An ORC DATE ordinal outside 0000-01-01 .. 9999-12-31, or equal to the proleptic-only 0000-02-29, now fails the scan instead of silently decoding as 1900-01-01. - Iceberg `year`, `month`, `day`, `hour` and `bucket` partition values now floor towards negative infinity and use the proleptic ordinal, so pre-1970 and year-zero rows land in the partitions the Iceberg specification defines (1969-12-31 23:59:59 is hour -1, not 0). Data files and manifests written by an older Doris keep the old values, so filtered scans in Doris and in Spark keep skipping those rows until the files are rewritten; run `rewrite_data_files` after every BE has been upgraded. During a rolling upgrade old and new BEs write both variants into the same table. ### Check List (For Author) - Test: Regression test + Unit Test - BE UT: data_type_datev2_serde_calendar_test, vdatetime_value_test (all 3,652,424 representable dates against cctz), partition_transformers_test, orc_reader_test - Regression: export_p0/outfile/test_outfile_date_year_zero, arrow_flight_sql_p0/test_date_year_zero, external_table_p0/iceberg/write/test_iceberg_write_partition_epoch_boundary - Behavior changed: Yes (see the Release note: the file/wire encoding of 59 year-zero DATE values and the Iceberg pre-1970 partition values change) - Does this need documentation: No (no in-repo or website document describes the DATE ordinal encoding or the Iceberg transform arithmetic) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPwhYhsSio1HYk2kFnx7KY
### What problem does this PR solve? Issue Number: closeapache#67447 Related PR: apache#67449 Problem Summary: Doris DATETIME starts at 0000-01-01 00:00:00, but the Parquet reader gated every timestamp at 0001-01-01, so a value Doris accepts, stores and exports could not be read back out of Doris's own file. Two independent defects sat behind that. MIN_DORIS_TIMESTAMP_MICROS was a whole year narrower than the type it materialises into: 0000-01-01 is 366 days below 0001-01-01, not 365, because year 0 IS a leap year in the proleptic Gregorian calendar that Parquet timestamps are defined in. The bound was also applied to the raw instant, before the timezone offset, so it lost representable values at both ends -- east of UTC a local 0001-01-01 00:00:00 is an instant below the civil minimum, and west of it a local 9999-12-31 23:59:59.999999 is one above the maximum. What stays in the shared helper is only a coarse guard (one day of slack, more than any real offset), keeping the unit-overflow and malformed-INT96 checks format-level; the exact civil range is now enforced per target type after the conversion. append_datetimev2_from_epoch_micros() then added calc_daynr(1970, 1, 1) straight to a proleptic Gregorian day ordinal. Doris follows MySQL's calendar, in which year 0 is not a leap year, so the two numberings disagree over 0000-01-01 .. 0000-02-28: 0000-01-01 computed daynr 0 and was rejected outright, and the rest of that window would have decoded one day early. Route it through epoch_days_to_daynr(), the helper the DATE reader already uses, which also rejects the proleptic-only 0000-02-29 rather than colliding it with 0000-02-28. TIMESTAMPTZ shares the same storage and the same helper, and both its error text and the type's own comment still advertised a year-one floor. The legacy scanner's zero-date compatibility for genuinely unrepresentable values is left alone: after this change year zero no longer reaches that path at all. A new SerDe test checks every representable day of year zero against cctz rather than against Doris's own day arithmetic, plus the two offset edges, INT96, the millis unit, plain, dictionary, decoded-value and raw-predicate materialization, and TIMESTAMPTZ. Reverting the three source files turns 12 of these red and leaves the two "still rejects out-of-range" cases green, so they cannot pass vacuously. End to end on a local cluster: the extended Iceberg suite now compares timestamp values, not just partition tuples, against Spark 4.0.0 in both directions (Doris-written read by both engines, Spark-written read by Doris) -- Iceberg `timestamp` is not adjusted to UTC, so it exercises the civil path. The OUTFILE round trip was verified over file:// plus local(), the reproduction from the report, across three session timezones including the two that straddle the range edges, plus the counts, the ORC control and INSERT ... SELECT. That run also showed the legacy scanner (enable_file_scanner_v2=false) returns NULL for any pre-epoch timestamp with a sub-second part, year zero or not, because it truncates a negative epoch value towards zero instead of flooring it. That is a separate defect in a path this change does not touch, so the new suite deliberately does not assert the two scanners agree. ### Release note Doris can now read back the year-zero DATETIME values it accepts, stores and exports: - The Parquet reader accepted no timestamp below 0001-01-01, so a DATETIME in 0000-01-01 .. 0000-12-31 written by Doris itself came back as NULL (or failed in strict mode). It is now read correctly, and the civil range is enforced after the timezone offset instead of before it, which also recovers the values at both ends of the range that a non-UTC session timezone used to lose. - The 0000-01-01 .. 0000-02-28 window decoded one day early and 0000-01-01 was rejected outright, because a proleptic Gregorian day ordinal was added to Doris's MySQL day number; both now decode correctly, and the proleptic-only 0000-02-29 is rejected instead of colliding with 0000-02-28. - TIMESTAMPTZ shares the same storage and helper and gains the same range. ### Check List (For Author) - Test: Regression test + Unit Test - BE UT: data_type_datetimev2_serde_calendar_test (every representable day of year zero against cctz, both offset edges, INT96, the millis unit, plain, dictionary, decoded-value and raw-predicate materialization, TIMESTAMPTZ), data_type_serde_parquet_test - Regression: export_p0/outfile/test_outfile_datetime_year_zero, external_table_p0/iceberg/write/test_iceberg_write_partition_epoch_boundary - Manual: local cluster, OUTFILE round trip over file:// and local(), three session timezones, INSERT ... SELECT from the exported file - Behavior changed: Yes (see the Release note: year-zero DATETIME/TIMESTAMPTZ values that used to read back as NULL are now returned) - Does this need documentation: No (no document states the DATETIME lower bound of the Parquet read path) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPwhYhsSio1HYk2kFnx7KY
### What problem does this PR solve? Issue Number: closeapache#67366 Related PR: apache#67449 Problem Summary: Review follow-up on apache#67449. The two commits before this one moved the DATE row decoders onto the proleptic Gregorian ordinal but left three consumers of the same ordinal behind, and the Iceberg transforms kept one pre-existing defect. format_v2's ORC reader still converted DATE stripe statistics through `date_day_offset_dict`, whose out-of-table fallback is the old `1900-01-01 + (value + 25567)` arithmetic. Those zone maps have exactly one consumer, `OrcReader::get_aggregate_result`, which answers a pushed-down MIN/MAX from statistics without reading a row, so on the default path (`enable_file_scanner_v2` and `enable_push_down_no_group_agg`) `MIN(d)` reported 1900-01-01 for a file whose smallest row is 0000-01-01 -- a value present in no row at all. Decode the bounds with the same conversion the rows use and return false when a bound has no Doris DATE, which makes the reader fall back to a normal row scan. `ReadMapDecimalDateWithCenturyBoundary` pinned that dictionary fallback: it wrote the ORC ordinal -719530 and expected 1900-01-01, which the new decoder rejects. Point the fixture at 0000-01-01, the smallest representable DATE, and add a dedicated test that the three unrepresentable shapes (-719530, the proleptic-only -719469 and 2932897) fail the scan. Iceberg's `bucket` over a timestamp hashes the full microsecond value (iceberg-api `Bucket.BucketLong` over `BucketUtil.hash(long)`), but Doris hashed whole seconds times a million, so a DATETIME(6) row landed in a different bucket than the same row written by Spark and bucket pruning skipped the Doris-written file. `human_year()` did not zero-pad, so a year-zero row's partition directory was `..._year=0` where Spark writes `..._year=0000`. On the FE side, `IcebergPartitionUtils` rendered MTMV partition-range bounds with the pattern letter `y` (year-of-era), so the ordinal -719528 the `day` transform now emits for 0000-01-01 rendered as 0001-01-01 and collided with the range of the real 0001-01-01 partition. The `ExpressionEstimation` hunk from the first commit is reverted: DATE/DATETIME column statistics are `yyyyMMddHHmmss` numbers, but `getDatetimeFromLong()` reads them as epoch seconds, so every value is >= 101000000 and the year-zero correction could never fire. The real defect (the encoding mismatch) predates this PR and belongs in its own change with its own test. Also in this commit: the shared out-of-range message no longer says "Parquet" for an ORC file and now carries the offending value, the ORC and Arrow callers prepend the column name, the epoch-day constants are derived from the daynr domain instead of typed out, the Arrow date64 branch spells its contract like the date32 one, and `epoch_date()`/`epoch_datetime()` plus their two cast includes are deleted (no caller left). ### Release note - A pushed-down `MIN(date_col)` / `MAX(date_col)` over an ORC file now agrees with the rows. It previously answered from stripe statistics decoded in Doris's own calendar, so a file containing 0000-01-01 .. 0000-02-28 reported a bound that no row holds (1900-01-01 for 0000-01-01). - Iceberg `bucket(n, ts)` now hashes the full microsecond value, as the Iceberg specification requires. A `DATETIME` column with a non-zero sub-second part routes to a different (and now Spark-compatible) bucket than before; existing files keep their old bucket values until they are rewritten. - An Iceberg `year` partition directory is zero-padded (`..._year=0000`), which is what Spark writes. Only the directory name changes; the manifest value is unaffected. - The MTMV partition-range view of an Iceberg table with year-zero `day`/`hour` partitions renders 0000-01-01 instead of 0001-01-01. - An unrepresentable DATE in a Parquet or ORC file is now reported with the offending value and the column name, and no longer names the wrong format. ### Check List (For Author) - Test: Regression test + Unit Test - BE UT: orc_reader_test (year-zero rows, a null row, the three rejected ordinals, MIN/MAX against the row decode, and the statistics fallback), data_type_datev2_serde_calendar_test (the Parquet/ORC decoded-value entry point, strict and null-on-failure), partition_transformers_test (microsecond buckets, the zero-padded human year), vdatetime_value_test - FE UT: IcebergPartitionUtilsTest (year-zero day and hour ranges) - Regression: the ORC half of external_table_p0/iceberg/write/test_iceberg_write_partition_epoch_boundary compares a pushed-down MIN/MAX with the rows of the same file - Behavior changed: Yes (see the Release note) - Does this need documentation: No Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPwhYhsSio1HYk2kFnx7KY
morningmanforce-pushed
the
wt-adbc-67366
branch
from
September 2, 2026 23:39
a5b8251 to
980f576Comparemorningman
requested review from
CalvinKirs, Gabriel39 and yiguolei
as code ownersSeptember 2, 2026 23:39
morningman
commented
Sep 3, 2026
ContributorAuthor
run buildall |
hello-stephen
commented
Sep 3, 2026
Contributor
TPC-H: Total hot run time: 16935 ms |
hello-stephen
commented
Sep 3, 2026
Contributor
TPC-DS: Total hot run time: 82064 ms |
hello-stephen
commented
Sep 3, 2026
Contributor
ClickBench: Total hot run time: 14.63 s |
hello-stephen
commented
Sep 3, 2026
Contributor
FE UT Coverage ReportIncrement line coverage `` 🎉 |
hello-stephen
commented
Sep 3, 2026
Contributor
BE Regression && UT Coverage ReportIncrement line coverage Increment coverage report
|
hello-stephen
commented
Sep 3, 2026
Contributor
FE Regression Coverage ReportIncrement line coverage |
morningman
marked this pull request as draft
September 3, 2026 13:33
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What problem does this PR solve?
Issue Number: close#67366
Issue Number: close#67447
Problem Summary:
Doris follows MySQL's calendar, in which year 0 is not a leap year:
0000-02-29does not exist and
calc_daynr()therefore numbers0000-01-01 .. 0000-02-28oneday ahead of the proleptic Gregorian calendar. Arrow
date32, ParquetDATE, ORCDATEand the Iceberg specification all define their day ordinal in the prolepticGregorian calendar, where year 0 IS a leap year; the two numberings coincide only
from
0000-03-01onwards.1. The DATE ordinal at every format boundary (#67366). Each boundary added or
subtracted the epoch day number directly, so all 59 dates in the year-zero window
were shipped one day late:
0000-01-01left Doris as-719527, which Arrow rendersas
0000-01-02, while the MySQL protocol -- which carries year/month/day verbatimand never needs a calendar -- reported
0000-01-01for the same value. New helpersdaynr_to_epoch_days()/epoch_days_to_daynr()now carry every such conversion:Arrow
date32/date64read and write (which Parquet export shares), the ParquetDATEreader, and the ORCDATEreader and writer. Both compile to branchlesscode. The ORC reader passes the file's ordinal straight through instead of
laundering it through
date_day_offset_dict, which also removes a silent fallbackthat decoded any out-of-dictionary value as
1900-01-01; the Arrowdate32readernow checks the result of
get_date_from_daynr()instead of discarding it.2. The Iceberg partition transforms (#67366). They were derived from
datetime_diff(), which rounds towards zero, while Iceberg floors(
DateTimeUtil.convertDays/convertMicrosevaluate one unit later for a negativeinput and then subtract one). Every pre-1970 value not exactly on a unit boundary
got the wrong partition:
1969-12-31 23:59:59landed in the same day and hourpartition as
1970-01-01 00:00:00, andyear()/month()reported 0 and -6 for1969-06-15where Iceberg requires -1 and -7.year,month,day,hourandbucketare rewritten for both DATE and TIMESTAMP;human_hour()floors as well,and
human_year()zero-pads likeTransformUtil.humanYear.bucket(n, ts)nowhashes the full microsecond value the specification defines instead of whole
seconds times a million.
3. Year-zero DATETIME on the Parquet read path (#67447).
MIN_DORIS_TIMESTAMP_MICROSwas a whole year narrower than the type it materialises into (year 0 has 366 days,
not 365) and was applied to the raw instant before the timezone offset, so a
DATETIME Doris accepts, stores and exports could not be read back out of Doris's own
file, and a non-UTC session timezone lost representable values at both ends. The
shared helper now keeps only a coarse format-level guard; the exact civil range is
enforced per target type after conversion.
append_datetimev2_from_epoch_micros()goes through
epoch_days_to_daynr()as well. TIMESTAMPTZ shares the storage and thehelper and gains the same range.
4. ORC DATE statistics.
format_v2's ORC reader converted DATE stripe statisticsthrough
date_day_offset_dict, and those zone maps have exactly one consumer:OrcReader::get_aggregate_result, which answers a pushed-down MIN/MAX fromstatistics without reading a row. On the default path
MIN(d)therefore reported1900-01-01for a file whose smallest row is0000-01-01-- a value present in norow. The bounds are now decoded with the row decoder's conversion, and a bound with
no Doris DATE disables the statistics so MIN/MAX falls back to a row scan.
5. FE partition-range rendering.
IcebergPartitionUtilsrendered MTMVpartition-range bounds with the pattern letter
y(year-of-era), so the ordinal-719528thedaytransform now emits for0000-01-01rendered as0001-01-01and collided with the range of the real
0001-01-01partition.Verified against cctz over all 3,652,424 representable Doris dates, and against the
Apache Iceberg reference implementation (iceberg-api 1.10.1, after reproducing the
specification's own Appendix B test vector
2017-11-16 -> -653330422). The Icebergregression suite writes identical boundary rows from Doris and from Spark into two
identically partitioned tables and compares the resulting partition metadata, so the
transforms are checked against the reference implementation end to end rather than
against Doris's own expectations.
Release note
Doris now encodes and decodes DATE and year-zero DATETIME at file and wire
boundaries the way the format specifications define them. This changes behaviour:
date32/date64, ParquetDATEand ORCDATEwritten by Doris encode
0000-01-01as-719528instead of-719527. Fileswritten by an older Doris read back one day later than before (
0000-01-01comesback as
0000-01-02), except an old-encoded0000-02-28(ordinal-719469),which is the proleptic-only
0000-02-29and has no Doris DATE: an ORC scan fails,a Parquet scan fails in strict mode and returns NULL otherwise, and Arrow input
fails. Rewrite such files from the source table to repair them.
date32input (stream loadformat=arrow, the ADBC catalog, theremote-Doris catalog, the Paimon native reader, Python UDTFs) now reports an
out-of-range day instead of silently storing a zero date. Federating an older and a
newer Doris through the remote-Doris catalog shifts year-zero dates by one day in
the mixed-version window, and fails on an old-encoded
0000-02-28.0000-01-01 .. 9999-12-31, orequal to the proleptic-only
0000-02-29, now fails the scan instead of silentlydecoding as
1900-01-01. The message carries the offending value and the columnname and no longer names the wrong format.
year,month,day,hourandbucketnow floortowards negative infinity and use the proleptic ordinal, so pre-1970 and year-zero
rows land in the partitions the specification defines (
1969-12-31 23:59:59ishour -1, not 0).
bucket(n, ts)hashes the full microsecond value, so a DATETIMEwith a non-zero sub-second part routes to a different (and now Spark-compatible)
bucket. A
yearpartition directory is zero-padded (..._year=0000). Data filesand manifests written by an older Doris keep the old values, so filtered scans in
Doris and in Spark keep skipping those rows until the files are rewritten: run
rewrite_data_filesafter every BE has been upgraded. During a rolling upgrade oldand new BEs write both variants into the same table.
NULL (or failing in strict mode), including under a non-UTC session timezone.
day/hourpartitions renders
0000-01-01instead of0001-01-01.Check List (For Author)
data_type_datev2_serde_calendar_test,data_type_datetimev2_serde_calendar_test,data_type_serde_parquet_test,vdatetime_value_test(all 3,652,424 representable dates against cctz, ordinaland civil date),
partition_transformers_test,orc_reader_testIcebergPartitionUtilsTestexport_p0/outfile/test_outfile_date_year_zero,export_p0/outfile/test_outfile_datetime_year_zero,arrow_flight_sql_p0/test_date_year_zero,external_table_p0/iceberg/write/test_iceberg_write_partition_epoch_boundaryfile://andlocal(), threesession timezones,
INSERT ... SELECTfrom the exported fileordinal encoding, the DATETIME lower bound of the Parquet reader, or the Iceberg
transform arithmetic)