Project

General

Profile

Bug #11421

Some dates mismatch after being persisted in a table

Added by Artur Școlnic 6 months ago. Updated 7 days ago.

Status:
Review
Priority:
Normal
Target version:
-
Start date:
Due date:
% Done:

100%

billable:
No
vendor_id:
GCD
case_num:
version_reported:
version_resolved:
production:
No
env_name:
topics:

History

#1 Updated by Artur Școlnic 6 months ago

The issue comes from inconsistent calendar handling during date conversions, not from any specific date. Different parts of the code rely on Java’s GregorianCalendar, which implicitly mixes Julian and Gregorian rules depending on how it’s initialized, leading to different millisecond values for the same logical date and, after persistence, a one-day shift.

A key point here is that Java’s modern date/time model follows the Proleptic Gregorian calendar, which applies Gregorian rules backward in time to keep calculations consistent. This differs from historical reality, where the Julian calendar was used before the Gregorian calendar reform, and the gap between the two systems varies over time. Because our code paths are not consistently aligned with a single calendar system, conversions can yield slightly different results, which then surface as off by a number of days errors.

I suggest we revisit how we use the Calendar, Date, and OffsetDateTime APIs and consider moving to a more consistent approach, or at least ensuring they are used in a uniform way across the codebase.

#3 Updated by Artur Școlnic 6 months ago

  • Status changed from New to WIP
  • Assignee set to Artur Școlnic
  • reviewer Ovidiu Maxiniuc added

#4 Updated by Ovidiu Maxiniuc 6 months ago

There are three issues to be handled here:
  • the 1-day difference for 'more recent dates' is, in my current opinion, caused by the date arithmetic involving timezone component while intermediary conversion to datetime(-tz) values. I think this complicates the computations, also affecting performance;
  • r16540 (11370b) addressed only the 0-year component of proleptic Gregorian calendar. The date skew need also be fixed. Ancient (pre oct' 1582) dates are incorrectly converted to OffsetDateTime (the intermediary native date type used low-level in BaseRecord.data[]) and eventually persisted to databases;
  • date format issue only for negative years (in date.getIsoDate()) causing to display them as 000-100 instead of -100.

#5 Updated by Artur Școlnic 6 months ago

Ovidiu, I committed the first round of fixes to 11421a. I still have to address the getIsoDate issue. If you have a few minutes, please look in the branch and confirm that the fixes are right, thanks.

#6 Updated by Ovidiu Maxiniuc 6 months ago

In date.java:4579: the line seems useless. The assignment of time field (setTimeInMillis()) is overwritten a few lines below (at 4588), after millis is slidden to UTC.

In datetimetz.java, I think the idea of the algorithm is correct. This can be checked with an inspection of the value in BaseRecord.data[] and query after the record is flushed to SQL. Excepting the formatting issues, we can improve performance:
  • apply correction only if the offsetDays is 0 and possibly stick to original code if the date is recent (year > 1582). I am not aware how costly are the new time-related operations (I guess they require validations and other non-trivial arithmetic), but usually we will be processing values from latest centuries, and any computation shortcut is useful. In other words, let's spend extra CPU cycles strictly on exceptions, not on normal dates.
  • the thresholds checks from julianToGregorianOffsetDays() can be simplified. For example: && !d.isAfter(LocalDate.of(1582, 10, 14) from the second condition can be dropped since that was the exit condition from previous if.

#7 Updated by Artur Școlnic 6 months ago

Ovidiu Maxiniuc wrote:

In date.java:4579: the line seems useless. The assignment of time field (setTimeInMillis()) is overwritten a few lines below (at 4588), after millis is slidden to UTC.

I know it seems like it, but this solved a corner case, the calendar is a mutable, reused variable, something about setting the time prior to the other operations made it behave correctly.
This is the test case it solved


define temp-table tt1 fields dt as date.
DEFINE VARIABLE my-date AS DATE NO-UNDO.

my-date = DATE("02/28/1900").

create tt1.
tt1.dt = my-date.
release tt1.
find first tt1.
message tt1.dt = my-date. // FWD -> false

#8 Updated by Ovidiu Maxiniuc 6 months ago

It prints yes for me in both cases (with and without the extra setTimeInMillis()).

But there is something that does not look fine. Inspecting the tt1 buffer with my IDE the date is, indeed, {java.sql.Date@13275} "1900-02-28". But when expanded, it is odd: it has the time component set to 1:44:24 only to balance it out with +144 timezone:

result = {com.goldencode.p2j.persist.TemporaryBuffer@13189} tt1: 2304 [null, null, null, null, null, null, 1900-02-28]RecordState{CACHED }/1
└ currentRecord = {com.goldencode.work.dmo._temp.Tt1_1_1__Impl__@13190} tt1: 2304 [null, null, null, null, null, null, 1900-02-28]RecordState{CACHED }
  └ data = {java.lang.Object[7]@13194} 
    └ 6 = {java.sql.Date@13275} "1900-02-28" 
├ cdate = {sun.util.calendar.Gregorian$Date@14479} "1900-02-28T01:44:24.000+0144"
└ fastTime = -2203977600000

OTOH, evaluating new java.sql.Date(0, 1, 28) results in:

result = {java.sql.Date@15334} "1900-02-28" 
├ cdate = {sun.util.calendar.Gregorian$Date@15336} "1900-02-28T00:00:00.000+0144" 
└ fastTime = -2203983864000
No time, but the same timezone.

#9 Updated by Artur Școlnic 8 days ago

I added tests for the issues discussed here and some variations.
Test class What it tests FWD result Why it fails on FWD
TestDateConstruction Every way of building a date (DATE(m,d,y), text in mdy/dmy/ymd, DATE(int), from DATETIME and DATETIME-TZ) gives the right day number in every era; gap dates, Julian-only leap days, invalid dates, DATETIME-TZ range 2 of 11 fail DATE(10,5,1582) gives day 2299014 instead of moving forward to 10/15/1582 (2299162); no error 12427 when a DATETIME-TZ goes past 12/31/32767 in UTC
TestCalendarArithmetic Date ±days, INTERVAL and ADD-INTERVAL across the 1582 gap and Julian leap days, differences between dates, UTC shifts in every era 4 of 10 fail BC years print as 000-1-12-31 instead of -001-12-31 (2 tests); ADD-INTERVAL(09/15/1582, 1, "months") gives 10/15, OpenEdge gives 10/25; DATETIME/DATETIME-TZ differences between modern dates are wrong, probably daylight saving
TestIsoFormat ISO-DATE and STRING output for years -100, -001 and 0001 for DATE, DATETIME and DATETIME-TZ 3 of 4 fail Negative years print as 000-1-12-31 / 000-100-... instead of -001-12-31 / -100-... (the getIsoDate bug in #11421)
TestDateStorage DATE keeps its day number through a variable, a NO-UNDO temp-table (including an extent field), an undoable temp-table and the database: in the buffer, after re-reading, in WHERE equality, sorting and range counts 4 of 28 fail PostgreSQL rejects Julian-only dates (1500-02-29, 0101-02-29 BC) because it uses the proleptic Gregorian calendar (3 database tests); the temp-table range 10/04/1582..10/15/1582 counts 3 rows instead of 2
TestDatetimeStorage DATETIME keeps its day and time of day (5 times) through a variable, a temp-table and the database 5 of 20 fail PostgreSQL rejects Julian-only dates (Julian centuries, BC); 1900 dates pick up the Bucharest historical offset, so 01/01/1900 00:00 comes back as 00:15:36 (temp-table and database); at the extremes the stored value doesn't match in a WHERE
TestDatetimeTzStorage DATETIME-TZ keeps its date, time, offset and ISO text (5 times × 5 offsets) through a variable, a temp-table and the database; includes the #11370 1 BC case 8 of 21 fail Pre-1582 values lose a day already in the buffer (02/28/1500 becomes 1500-02-27); BC values print as 000-1-..., and BC storage fails with an error that has no message; at -4713 the date part comes back as day -35
TestSessionTimezone SESSION:TIMEZONE (unset, 0, ±, +104, changed between write and read) doesn't move stored DATE, DATETIME or DATETIME-TZ values 9 of 9 fail Every test stops at the database write of 02/29/1500: PostgreSQL rejects the Julian-only date, so the time-zone checks themselves never run
TestDateTransfer Dates survive BUFFER-COPY through the database, dynamic BUFFER-VALUE, a date in a dynamic WHERE, TABLE parameters, EXPORT/IMPORT, JSON and XML 6 of 7 fail PostgreSQL rejects Julian-only dates (BUFFER-COPY, dynamic query); a TABLE parameter loses a day for 1700–1800 dates (01/01/1800: 2378497 instead of 2378498); EXPORT/IMPORT, JSON and XML fail with an error that has no message, likely the BC year format

Totals: 69 of 110 pass on FWD; all 110 passed on OpenEdge.

Added tests to tests/persistence/calendar_dates/

#10 Updated by Artur Școlnic 7 days ago

  • % Done changed from 0 to 100
  • Status changed from WIP to Review

FWD converted dates to and from Java date types in several inconsistent ways. Some conversions used the JVM time zone, with its historical offsets such as Bucharest's +01:44:24. Some used GregorianCalendar's mixed Julian/Gregorian calendar. Others used a hand-made Julian correction table. A date could therefore come back from a table one day off, or with an odd time of day.

  • One conversion. The new class TemporalCodec is the only place that converts the 4GL date types to Java date types. It works from the day number alone, with no time zone and no calendar object. Records store DATE and DATETIME values in one form: UTC millis of the day. Record, DateType, DatetimeType, DatetimetzType, TypeManager and JsonExport all go through the codec.
  • Database binding. The new class TemporalBinding binds stored values to the database.
    • PostgreSQL, MariaDB and SQL Server receive the values through a UTC calendar. They store the same 4GL labels as before, so existing data stays valid and needs no migration.
    • H2 temp-tables receive java.time values and store the true day, as before. DirectAccessHelper now passes its lookup keys the same way.
  • DATETIME-TZ. offsetDateTimeValue() and datetimetz(OffsetDateTime) go through the codec. The Julian/Gregorian correction table, which was a day off around century leap days, is removed.
  • Negative years. ISO-DATE writes year −1 as -001, as OpenEdge does, instead of 000-1. The ISO parsers for DATETIME and DATETIME-TZ accept this form.

Testing: tests/persistence/calendar_dates passes 97 of 147 with the server in America/New_York (79 before) and 98 of 147 in Europe/Bucharest (76 before). No test that passed before fails in either zone.

Known limits: PostgreSQL still rejects Julian-only leap days such as 02/29/1500. ADD-INTERVAL/INTERVAL across the 1582 gap, error 12427, and STRING for negative years are out of scope.

Ovidiu, please review 11421a.

Also available in: Atom PDF