andygrove opened a new issue, #3315:
URL: https://github.com/apache/iceberg-rust/issues/3315

   ### Apache Iceberg Rust version
   
   main @ `bb1e4a4861f02377489eff818b75138f414c4cb0`
   
   ### Describe the bug
   
   `Day::day_timestamp_micro` and `Day::day_timestamp_nano` in 
`crates/iceberg/src/transform/temporal.rs` take the whole seconds of the value 
with `v / MICROS_PER_SECOND` (and `v / NANOS_PER_SECOND`). Rust's `/` truncates 
toward zero, but the fraction of the second comes from `(v + 
1).rem_euclid(...)`, which floors. For a negative value that is not on a whole 
second, the seconds come out one too high. When that extra second crosses a 
midnight, the day count moves to the next day. So a timestamp in the last 
second of any day before 1969-12-31 gets the day after it, unless its 
microsecond of second is 0 or 999999 (nanosecond of second 0 or 999999999 for 
`timestamp_ns`).
   
   `Day::transform` and `Day::transform_literal` both call these functions. So 
a table written through iceberg-rust puts these rows in a different `day` 
partition from the one iceberg-java writes them to. `Transform::project` also 
computes the day of a predicate's literal through the same function.
   
   ### To Reproduce
   
   ```rust
   use std::sync::Arc;
   
   use arrow_array::{ArrayRef, TimestampMicrosecondArray};
   use iceberg::spec::{Datum, Transform};
   use iceberg::transform::create_transform_function;
   
   let day = create_transform_function(&Transform::Day)?;
   let input: ArrayRef = Arc::new(TimestampMicrosecondArray::from(vec![
       -86_400_500_000,     // 1969-12-30T23:59:59.500000
       -86_400_000_002,     // 1969-12-30T23:59:59.999998
       -31_536_000_500_000, // 1968-12-31T23:59:59.500000
       -86_401_000_000,     // 1969-12-30T23:59:59.000000
       -500_000,            // 1969-12-31T23:59:59.500000
   ]));
   let days = day.transform(input)?; // Date32Array [-1, -1, -365, -2, -1]
   let literal = 
day.transform_literal(&Datum::timestamp_micros(-86_400_500_000))?;
   // Some(Datum { type: Date, literal: Int(-1) })
   ```
   
   | Input (micros)        | Timestamp                  | iceberg-rust `day` | 
iceberg-java `day` |
   | --------------------- | -------------------------- | ------------------ | 
------------------ |
   | `-86_400_500_000`     | 1969-12-30T23:59:59.500000 | -1 (1969-12-31)    | 
-2 (1969-12-30)    |
   | `-86_400_000_002`     | 1969-12-30T23:59:59.999998 | -1 (1969-12-31)    | 
-2 (1969-12-30)    |
   | `-31_536_000_500_000` | 1968-12-31T23:59:59.500000 | -365 (1969-01-01)  | 
-366 (1968-12-31)  |
   | `-86_401_000_000`     | 1969-12-30T23:59:59.000000 | -2                 | 
-2                 |
   | `-500_000`            | 1969-12-31T23:59:59.500000 | -1                 | 
-1                 |
   
   `timestamp_ns` behaves the same way: `-86_400_500_000_000` nanoseconds gives 
day -1, where iceberg-java gives -2.
   
   I ran this at `bb1e4a4`. On `main` (`de19a39`) the two functions differ only 
in how they build their error.
   
   ### Expected behavior
   
   The days in the iceberg-java column above. That column is `Transforms.day()` 
bound to `timestamp` and to `timestamptz`, from the 1.5.2 and 1.11.0 runtimes, 
and bound to `timestamp_ns` in 1.11.0. They go through 
`DateTimeUtil.microsToDays` and `nanosToDays`, which take the seconds with 
`Math.floorDiv`:
   
   ```java
   long epochSecond = Math.floorDiv(micros, MICROS_PER_SECOND);
   long nanoAdjustment = Math.floorMod(micros + 1, MICROS_PER_SECOND) * 1000;
   return (int) granularity.between(EPOCH, toOffsetDateTime(epochSecond, 
nanoAdjustment)) - 1;
   ```
   
   The partition transforms table in the spec says:
   
   > | **`day`** | Extract a date or timestamp day, as days from 1970-01-01 | 
`date`, `timestamp`, `timestamptz`, `timestamp_ns`, `timestamptz_ns` | `date` |
   
   1969-12-30T23:59:59.5 is on 1969-12-30, which is day -2.
   
   **Possible fix.** Floor the seconds as Java does: 
`v.div_euclid(MICROS_PER_SECOND)` in `day_timestamp_micro` and 
`v.div_euclid(NANOS_PER_SECOND)` in `day_timestamp_nano`. I compiled both 
functions with only that change and compared them with 
`DateTimeUtil.microsToDays` and `nanosToDays` from Iceberg 1.11.0 on 424,120 
values each: offsets around every midnight from 800 days before the epoch, plus 
random values. None differed. The current functions differ on 108,041 and 
108,938 of them.
   
   Flooring only the seconds, rather than dividing the whole value by the 
length of a day, also keeps a quirk of iceberg-java's. Java puts a pre-epoch 
timestamp exactly 999999 microseconds after midnight in the day before, so 
`1969-01-01T00:00:00.999999` is day 1968-12-31 (-366). iceberg-rust returns 
-365 for it today. iceberg-rust's `year`, `month` and `hour` also differ from 
iceberg-java on those timestamps, because they floor. That is a separate 
question. Comet ran into both while fixing 
https://github.com/apache/datafusion-comet/issues/6426.
   
   ### Willingness to contribute
   
   I would be willing to contribute a fix for this bug with guidance from the 
Iceberg community.
   
   ---
   
   **Related upstream work.** #3022 moved `year`, `month` and `hour` to compute 
from the epoch, and says "Day already aligns with Java". #3237 (open) says 
`Day` and `Hour` already work from the raw epoch. Neither changes 
`day_timestamp_micro` or `day_timestamp_nano`.
   
   _This report was drafted with LLM assistance (Claude Code); the behaviour 
was verified with a Rust reproducer against iceberg-rust and against Iceberg 
Java's `DateTimeUtil` and `Transforms` (1.5.2 and 1.11.0)._
   


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