| There seems to be a problem for some date/times around 1900, indeed. And it also seems to disappear if we change the conversion strategy in LocalDateTimeJavaDescriptor to rely on Timestamp.valueOf(LocalDate)/Timestamp.toLocalDate() instead of using an intermediary instant. It's not as simple as the old strategy being simply incorrect, however. I investigated for quite some time, and I'm still struggling to make sense of this, but I will try to explain the best I can. I created two test cases in a GitHub repository: https://github.com/yrodiere/jdk-bugs
- TimestampWriteThenReadTest reproduces what happens in Hibernate and the JDBC drivers when LocalDateTimes are written to/read from the database. It uses the JDK exclusively (no Hibernate, no JDBC), and we get unexpected behavior too.
- JdkChronoFieldsToEpochMillisConversionTest explores the bug in more details by testing the conversion from chrono fields (year/month/etc.) to milliseconds since the epoch in both Timestamp and Calendar. Surprise: it also fails. At the very least the results are inconsistent with the java.time APIs (hence the problems when converting from the date/time APIs).
What's the root of the problem? So the first conclusion is the problem really seems to lie in the JDK. I am not sure if it's a bug or just yet another quirk of the legacy date/time APIs, but it's definitely in the JDK. The second conclusion is that the problem seems to be about converting chrono fields to/from milliseconds since the epoch. When you set a Calendar to a number of milliseconds, then get that number back, you're fine; when you set its chrono fields then get back these chrono fields, you're fine; but as soon as you mix them, the bug shallows. Same idea for the timestamp. Since JDBC drivers rely on these mechanics to build a string representation of the timestamp, ultimately they are all affected by the problem. In more details, it seems the mechanics responsible for translating a number of millisecond since the epoch into a year/month/day/hours/minutes/etc. (and back) in java.util produce different results from java.time. Some examples:
- Converting 1900-01-01T00:59:59 to milliseconds since the epoch with Europe/Amsterdam as default timezone will give -2208986373000 (consistent with java.time)
- Converting -2208986373000 to chrono fields with Europe/Amsterdam as default timezone will give 1900-01-01T00:59:59 (consistent with java.time)
- Converting 1900-01-01T00:19:31 to milliseconds since the epoch with Europe/Amsterdam as default timezone will give -2208991229000 (inconsistent with java.time, expected -2208988801000)
- Converting -2209027229000 to chrono fields with Europe/Amsterdam as default timezone will give 1900-01-31T00:19:31 (inconsistent with java.time, expected 1899-12-31T23:39:03)
- Converting -2208988801000 to chrono fields with Europe/Amsterdam as default timezone will give 1900-01-01T00:59:59 (inconsistent with java.time, expected 1900-01-01T00:19:31)
How does this affect us? As you can see, on top of being inconsistent with java.time, it's sometimes inconsistent with itself:
- 1900-01-01T00:19:31 is converted to -2208991229000 (wrong locally), which is converted back to 1900-01-01T00:19:31 (wrong locally, correct overall)
- -2208988801000 is converted to 1900-01-01T00:59:59 (wrong locally), which is converted back to -2208986373000 (correct locally, wrong overall)
This internal inconsistency explains why our "old" strategy for LocalDateTime conversion did not work, while the new one does. Conversion inconsistencies happen this way:
- They only appear before a certain moment (which depends on the time zone). Let's call this moment "breaking point".
- When converting from chrono fields to millisecond since the epoch, the result is a number before the one expected (drift backward in time)
- When converting from milliseconds since the epoch to chrono fields, the result is a date after the one expected (drift forward in time)
As a result, if you convert first from milliseconds to chrono fields, then from chrono fields to milliseconds, you run the risk of the original value being affected by the drift, but not the "drifted", intermediary value, in which case the first conversion will drift the value, but not the second conversion, and the final result will wrong overall. That is what used to happen, and what is demonstrated it the example number 2 above. On the other hand, if you convert first from chrono fields to milliseconds, then from milliseconds to chrono fields, then the intermediary value will be wrong too, but conversions will lead to opposing drifts, the drifts will compensate and the final result will be correct overall. This is what will happen with the new strategy for LocalDateTime conversion, and what is demonstrated it the example number 1 above. Misc Some more information below that I collected while investigating. Not necessarily useful if you read the information above, but maybe I'll need it later, so I'll just leave it here. The data flow of the "new" strategy looks like this:
And back from the database:
The data flow of the "old" method looks like this:
And back from the database:
|