morningman opened a new pull request, #67449:
URL: https://github.com/apache/doris/pull/67449

   ## Problem
   
   `DATE` values in year zero are shipped one day late by every format that 
encodes a day ordinal. `CAST('0000-01-01' AS DATE)` arrives as `0000-01-02` 
over Arrow Flight SQL while the MySQL protocol reports `0000-01-01` for the 
same value.
   
   Doris follows MySQL's calendar, where year 0 is **not** a leap year 
(`is_leap()` in `be/src/util/time_lut.h`): `0000-02-29` does not exist, so 
`calc_daynr()` numbers `0000-01-01 .. 0000-02-28` one day ahead of the 
proleptic Gregorian calendar. Arrow `date32`, Parquet `DATE`, ORC `DATE` and 
Iceberg all define their day ordinal in the proleptic Gregorian calendar, where 
year 0 **is** a leap year. The two numberings agree from `0000-03-01` onwards, 
so exactly 59 dates are affected.
   
   Every boundary added or subtracted the epoch day number directly instead of 
converting:
   
   | | Doris `daynr` | shipped `date32` | proleptic |
   |---|---:|---:|---:|
   | `0000-01-01` | 1 | -719527 | **-719528** |
   | `0000-02-28` | 59 | -719469 | **-719470** |
   | `0000-03-01` | 60 | -719468 | -719468 |
   | `2024-01-01` | 739251 | 19723 | 19723 |
   
   The MySQL protocol is unaffected because it carries `year`/`month`/`day` 
verbatim (`mysql_row_buffer.cpp`) and never needs a calendar at all.
   
   Doris was already inconsistent with itself: `DATETIMEV2` over Arrow goes 
through `unix_timestamp()` (cctz, proleptic) and was correct, while `DATEV2` 
was not; ORC predicate pushdown built its literals with `cctz::civil_day` 
(proleptic) while the ORC writer emitted Doris day numbers, so a pushed-down 
`WHERE d = '0000-01-01'` could prune the very row it was looking for; and 
`bucket(timestamp)` used `unix_timestamp()` while `bucket(date)` used `daynr()`.
   
   ### A second, independent defect in the Iceberg transforms
   
   The Iceberg partition transforms 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). This affects **every pre-1970 value** 
that is not exactly on a unit boundary, not just year zero:
   
   | | Doris (before) | Iceberg |
   |---|---:|---:|
   | `year('1969-06-15')` | 0 | **-1** |
   | `month('1969-06-15')` | -6 | **-7** |
   | `day('1969-12-31 23:59:59')` | 0 | **-1** |
   | `hour('1969-12-31 23:59:59')` | 0 | **-1** |
   
   `regression-test/data/.../test_iceberg_write_partition_types_null.out` had 
recorded this as expected: `1969-12-31 23:59:59` and `1970-01-01 00:00:00` were 
both stored with `p_ts_day_day = 1970-01-01` and `p_ts_hour_hour = 0`, i.e. 
**two distinct timestamps collapsed into one partition**.
   
   ## Solution
   
   Add `daynr_to_epoch_days()` / `epoch_days_to_daynr()` in `vdatetime_value.h` 
and route every format boundary through them. `calc_daynr()`, `is_leap()` and 
all SQL-level date semantics (`TO_DAYS`, `DATEDIFF`, partition pruning) are 
untouched.
   
   - **Arrow** `date32`/`date64` read and write. Parquet export shares the 
Arrow writer, so it is covered by the same change.
   - **Parquet** `DATE` reader (`decode_parquet_date`), which also backs 
min/max and dictionary predicates.
   - **ORC** `DATE` reader and writer. The reader now passes the file's day 
value straight through instead of laundering it through `date_day_offset_dict`; 
that lookup also had a silent fallback which decoded any out-of-dictionary 
value as `1900-01-01`, so a Spark-written `0000-01-01` used to read back as 
`1900-01-01`.
   - **Iceberg** `year`, `month`, `day`, `hour` and `bucket` for both `DATE` 
and `TIMESTAMP`, plus `human_hour()` so a negative hour ordinal renders as 
`1969-12-31-23` instead of `1970-01-01--1`.
   - **FE** `ExpressionEstimation`'s `to_days()`/`from_days()` min/max model, 
which had the same off-by-one.
   
   Two behaviours change deliberately: `0000-02-29` (proleptic-only) is now 
**rejected** rather than silently colliding with `0000-03-01`, and the Arrow 
`date32` reader now checks the result of `get_date_from_daynr()` instead of 
discarding it, matching what the `date64` path already did.
   
   Both helpers compile to branchless code (`cmp`/`cset`, `cinc`/`ccmp`/`csel` 
on arm64). Measured cost: **+0.19 ns per value on the write path**, read side a 
wash. Three paths got *faster* because they no longer go through 
`datetime_diff()` or a dictionary lookup: Iceberg `day(timestamp)` −38.7%, 
`hour(timestamp)` −37.1%, ORC read −4.2%.
   
   ## Compatibility
   
   Files previously written by Doris containing year-zero dates encoded 
`-719527` for `0000-01-01`; they will now read back as `0000-01-02`. Those 
files never conformed to the Parquet/ORC/Iceberg specs — Spark and Trino have 
always read them as `0000-01-02`. Iceberg partition values for pre-1970 data 
change to the spec-conformant values.
   
   ## Test
   
   **BE unit tests — 38/38 pass**
   
   - `epoch_days_conversion_matches_cctz_over_full_range` walks **all 3,652,424 
representable Doris dates** and compares against `cctz::civil_day` (proleptic 
Gregorian), plus a full round trip. Zero mismatches, 32 ms.
   - Boundary and rejection tests for the conversion helpers.
   - `DataTypeDateV2SerDeCalendarTest` (new): Arrow `date32`/`date64` read and 
write, null map, lossless round trip, rejection of `0000-02-29` and 
out-of-range values, and `ARRAY<DATE>` through the nested SerDe.
   - Seven new `PartitionTransformersTest` cases whose expected values were 
produced by **iceberg-api 1.10.1**, after first reproducing the spec's own 
Appendix B test vector (`2017-11-16` → `-653330422`) to prove the harness is 
faithful. The nine pre-existing transform cases (all using `2017-11-16`) still 
pass unchanged, which shows post-1970 behaviour is untouched.
   
   **Regression tests**
   
   - `arrow_flight_sql_p0/test_date_year_zero` (new): compares the MySQL and 
Arrow Flight protocols for scalar, nullable and `ARRAY<DATE>` columns. Reads 
through `getString()` rather than `getObject()`, because `java.sql.Date` runs 
year-zero values through the Julian/Gregorian hybrid calendar and would mask 
the difference.
   - 
`external_table_p0/iceberg/write/test_iceberg_write_partition_epoch_boundary` 
(new): writes identical boundary rows from **Doris and from Spark** into two 
identically partitioned Iceberg tables and compares the resulting `$partitions` 
metadata. All 7 rows × 8 partition fields match byte for byte. This suite has 
real discriminating power — run against a build without the `year`/`month` fix 
it correctly reported those two columns as divergent.
   - `export_p0/outfile/test_outfile_date_year_zero` (new): Parquet/ORC export 
and read-back through the S3 TVF.
   - `test_iceberg_write_partition_types_null.out` updated: the `1969-12-31` 
row moves from `0 / 0 / 1970-01-01 / 0` to `-1 / -1 / 1969-12-31 / -1`. The 
other rows are unchanged, confirming the change only touches pre-epoch values.
   - `test_iceberg_write_transform_partitions` and 
`test_iceberg_write_partition_path` pass unchanged.
   
   **Absolute verification of the on-disk encoding**: a raw byte scan of a 
Doris-exported Parquet file finds `-719528` and `-719470` in place, with the 
pre-fix `-719527`/`-719469` absent — so this is pinned against the file format, 
not merely against a symmetric round trip.
   
   Not run locally: `test_iceberg_write_partitions` needs the hive2 stack 
(containers were down), and the ADBC suites need `libadbc_driver_flightsql`, 
which is not installed in this thirdparty; their date literals start at 1900, 
well inside the range where both calendars agree.
   
   ## Related findings, filed separately
   
   Found while building and testing this, all unrelated to the change: #67445 
(BE unit tests do not compile on aarch64), #67446 (Iceberg writes on an FE 
start failing on a terminated worker pool), #67447 (year-zero `DATETIME` 
survives the Parquet write but is rejected on read), #67448 
(`kuromoji_build_dict` fails to link on arm64).
   
   Fixes #67366
   
   🤖 Generated with [Claude Code](https://claude.com/claude-code)
   
   https://claude.ai/code/session_01LPwhYhsSio1HYk2kFnx7KY
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to