andygrove commented on issue #5010:
URL: 
https://github.com/apache/datafusion-comet/issues/5010#issuecomment-5159469674

   ## Findings from attempting a fix (#5202, closed)
   
   I closed #5202 ("fail rather than silently return unrebased datetimes from 
Parquet") because the
   problem is substantially more complex than the issue text implies. #5221 now 
only removes the dead
   `spark.comet.exceptionOnDatetimeRebase` config for 1.0.0, so the correctness 
gap tracked here is
   untouched and still open.
   
   Recording what the attempt turned up, so the next person picking this up (or 
reviewing #5048)
   starts from it rather than rediscovering it. All of the below is verified 
against Spark's own
   sources, not inferred.
   
   ---
   
   ### A. Detection is much narrower than "does the file carry a legacy marker"
   
   **A1. The footer rule is "provably Proleptic Gregorian", not "provably 
legacy".** Spark's
   
[`DataSourceUtils.getRebaseSpec`](https://github.com/apache/spark/blob/master/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/DataSourceUtils.scala)
   resolves the policy as: if `org.apache.spark.version` is present, LEGACY when
   `version < minVersion || <marker key> != null`, else CORRECTED; **if the 
version key is absent
   entirely, fall through to the read-mode config.**
   
   The absent-version case is not hypothetical and is easy to get wrong: 
**Spark 2.4.5 and earlier
   wrote no `org.apache.spark.version` key at all**, so the canonical legacy 
files carry no metadata
   hint whatsoever. Any detector keyed on "marker present" or "version < 3.0.0" 
clears exactly the
   files that need rebasing most. Credit to @peterxcli — #5048 got this right 
and it is what caught
   the same bug in my first commit.
   
   Note Spark compares with `version < minVersion` as a **string**, not a 
semantic version compare. A
   native or JVM reimplementation should replicate the string comparison rather 
than "fixing" it, or
   it will diverge on odd version strings.
   
   **A2. Two markers, two thresholds, tracked independently.**
   `org.apache.spark.legacyDateTime` with `minVersion` 3.0.0 for 
date/`TIMESTAMP_MICROS`/
   `TIMESTAMP_MILLIS`, and `org.apache.spark.legacyINT96` with `minVersion` 
**3.1.0** for INT96.
   Collapsing them into one flag or one version threshold is wrong in both 
directions.
   
   **A3. Read modes come from `ParquetOptions`, not `SQLConf`.** Spark resolves
   `datetimeRebaseModeInRead` / `int96RebaseModeInRead` through
   `new ParquetOptions(options, conf)`, so a per-read 
`.option("datetimeRebaseMode", "CORRECTED")`
   overrides the session conf. Reading `SQLConf` directly silently ignores it. 
(Again @peterxcli's
   catch, from reviewing #5202.)
   
   **A4. The file-level marker says nothing about whether any value is actually 
affected.** Spark
   stamps `legacyDateTime` on a *whole file* whenever the write mode was 
LEGACY, regardless of the
   values — and dates from 1582-10-15 onward rebase to themselves
   (`RebaseDateTime.lastSwitchJulianDay == -141427`, and `julianGregDiffs.last 
== 0`). So a large
   fraction of marked files return perfectly correct results under Comet today. 
Marker-only detection
   over-triggers heavily: whichever remedy is chosen (raise, fall back, or 
rebase), it fires on reads
   that need nothing.
   
   Row-group min/max statistics are the only cheap discriminator, and they are 
only available where
   the footer is already in hand.
   
   **A5. Statistics cannot save INT96.** The Parquet spec gives INT96's 12 
bytes no meaningful
   ordering, so writers emit no usable min/max. INT96 columns therefore have to 
be handled
   conservatively regardless of statistics. Same for any row group with no 
statistics. An all-null row
   group *is* clear (no value to rebase), and so is an empty one.
   
   **A6. Thresholds must come from Spark, not from native constants.**
   `RebaseDateTime.lastSwitchJulianTs` is not a constant anyone should 
re-derive: it is
   `rebaseMap.values.map(_.switches.last).max` over Spark's **per-timezone** 
rebase tables, with a
   `require` that all diffs after it are zero. Read `lastSwitchJulianDay` and 
`lastSwitchJulianTs` on
   the JVM and send them through the proto, so the thresholds stay exact for 
whichever Spark version
   is in use. Scaling them to millis/nanos has to round toward the *unsafe* 
direction and saturate
   rather than overflow.
   
   **A7. Timestamp rebasing is timezone-dependent, and the timezone is per 
file.** When the resolved
   policy is LEGACY, Spark builds `RebaseSpec(LEGACY, 
Option(lookupFileMeta(SPARK_TIMEZONE_METADATA_KEY)))`
   — i.e. it rebases using the writer's `org.apache.spark.timeZone`, falling 
back to the session
   timezone. Any actual native rebase implementation (option 1 below) needs the 
full per-timezone
   tables and per-file timezone resolution, not a single global offset. This is 
the bulk of the work
   in option 1 and is why it is not a small change.
   
   **A8. The affected-column test must walk nested types.** Date/timestamp 
columns hide inside
   structs, lists, maps, dictionaries and unions, so the walk has to recurse — 
and it must consider
   only the *requested* top-level columns (case-insensitively), or reading a 
modern date column out of
   a file that also holds an ancient one fails for no reason.
   
   ---
   
   ### B. Where the check runs decides which remedies are even reachable
   
   **B1. Plan-time detection costs real planning latency.** #5048's 
`requiresDatetimeRebase` opens
   every selected file's footer on the driver, per query, for any query 
touching a date or timestamp
   column. Three things compound: it uses `selectedPartitions` filtered with
   `partitionFilters.filterNot(isDynamicPruningFilter)`, so it is the 
**pre-DPP** file set;
   `ParquetFileReader.open` reads the full footer including all row-group 
metadata rather than
   `ParquetFooterReader.readFooter(..., SKIP_ROW_GROUPS)`; and it is serial. On 
a table with thousands
   of files that is significant added planning time and driver heap, paid even 
when every file is
   fine.
   
   **B2. Read-time detection is free but cannot fall back.** In the native 
reader the footer has
   already been fetched and cached for the read itself, so inspection costs no 
extra I/O, and it is the
   only point where row-group statistics (A4) are available. But by then the 
plan is fixed: the only
   available outcome is to raise. **This is the core tension** — 
correct-results-via-fallback requires
   paying B1; free detection can only fail the query.
   
   **B3. If done natively, the enforcement point must be the reader factory's 
`get_metadata`.** Not the
   expression/schema adapter: DataFusion only constructs that when the logical 
and physical schemas
   differ or a predicate is pushed down, so a plain `SELECT d FROM t` skips it 
entirely.
   
   ---
   
   ### C. If the remedy is to raise, the error plumbing is not trivial
   
   **C1. It has to surface as Spark's own exception.** `SparkUpgradeException` 
with
   `INCONSISTENT_BEHAVIOR_CROSS_VERSION.READ_ANCIENT_DATETIME`. This matters 
mechanically, not just
   cosmetically: `FileScanRDD` deliberately rethrows `SparkUpgradeException` 
(two sites) instead of
   wrapping it in `FAILED_READ_FILE`/`Encountered error while reading file`, 
because the file is not
   corrupt. Anything that loses the type gets misreported as a corrupt-file 
error.
   
   **C2. Getting it out of the native reader needs a downcast, not message 
matching.**
   `AsyncFileReader::get_metadata` can only return a `ParquetError`, so the 
typed error has to travel
   boxed in `ParquetError::External` and be recovered by downcast in the error 
classifier.
   
   **C3. Unresolved: the message conflicts with Comet's actual remedy.** 
Spark's templated
   `READ_ANCIENT_DATETIME` message advises setting `datetimeRebaseModeInRead` 
to `LEGACY`/`CORRECTED`,
   which does not help under Comet — the remedy is to disable Comet for the 
query. Matching Spark's
   exception type means Comet's real guidance can only travel as the cause; a 
Comet-specific exception
   makes the guidance primary but is no longer a `SparkUpgradeException` (see 
C1). No good answer found.
   
   ---
   
   ### D. A raise-only remedy leaves a real divergence
   
   Under read mode `LEGACY` on a version-less file, Spark rebases the values 
and returns them
   correctly. Comet has no rebasing to do it with, so it can only refuse a read 
Spark completes
   successfully. Only option 1 closes this.
   
   ---
   
   ### E. Test assets that already exist, and one test that asserts the bug
   
   The 15 `before_1582_*` fixtures are already checked in under
   `spark/src/test/resources/test-data/` — date / `TIMESTAMP_MICROS` / 
`TIMESTAMP_MILLIS` / INT96 plain
   / INT96 dict, each written by Spark 2.4.5, 2.4.6 and 3.2.0. That covers 
every physical encoding and
   both footer paths: the 2.4.x files are the version-less case (A1), the 3.2.0 
files the
   marker-stamped case. Any fix should be validated against all 15.
   
   `ParquetReadSuite.scala:1714` (`"reading ancient dates before 1582"`) 
currently **asserts the wrong
   behaviour** — its comment states "no rebase, no exception". It has to change 
with any fix here.
   
   ---
   
   ### F. Also still outstanding after #5221
   
   #5221 removes the config from `CometConf.scala` and the `scans.md` sentence, 
but not the dead native
   field `SparkParquetOptions::use_legacy_date_timestamp_or_ntz`
   (`native/core/src/parquet/parquet_support.rs:78`, only ever initialized to 
`false` at lines 111 and
   127, never consulted). Both #5048 and #5202 removed it; neither has merged, 
so it survives. Worth
   removing independently.
   
   ---
   
   ### Where that leaves the options
   
   With the costs now measured, restating the choices in the issue body:
   
   1. **Rebase natively.** The only option with no divergence (D) and no 
spurious failures (A4), but
      it needs Spark's per-timezone rebase tables and per-file timezone 
resolution ported natively
      (A7). Largest change by a wide margin. #5047 (draft) is the existing 
attempt.
   2. **Fall back to Spark.** Always correct, but requires plan-time footer 
reads (B1) and cannot use
      row-group statistics, so it over-triggers (A4). The plan-time cost is the 
objection to #5048;
      it could be reduced (`SKIP_ROW_GROUPS`, post-DPP file set, parallelized) 
but not eliminated.
   3. **Raise.** Free to detect and precise thanks to statistics (B2, A4), but 
fails queries Spark
      answers (D) and needs the error plumbing in section C. This was #5202.
   
   My own read is that option 3 is not worth shipping on its own — turning 
silently-wrong results into
   a hard failure is an improvement, but a narrower one than it looks once D is 
accounted for, and it
   spends the error-plumbing complexity without moving toward option 1. Option 
2 with the plan-time
   cost reduced is the pragmatic interim, and option 1 is the real fix. Other 
opinions welcome —
   cc @peterxcli.
   


-- 
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