Skip to content

Timestamp: convert epoch milliseconds in the proleptic Gregorian calendar - #1165

Open
lenamonj wants to merge 2 commits into
amazon-ion:masterfrom
lenamonj:timestamp-proleptic-gregorian
Open

lenamonj wants to merge 2 commits into
amazon-ion:masterfrom
lenamonj:timestamp-proleptic-gregorian

Conversation

@lenamonj

@lenamonj lenamonj commented Sep 6, 2026 •

Copy link
Copy Markdown

Issue #, if available: #165

Description of changes:

Timestamp validates day-of-month with proleptic Gregorian leap rules but converted to and from epoch milliseconds through java.util.Date, which applies the Julian calendar before 1582-10-15. 1582-10-05T00:00:00Z and 1582-10-15T00:00:00Z shared one epoch millisecond, so compareTo called them equal while equals did not.

Both directions now go through java.time's LocalDateTime (ISO chronology, proleptic Gregorian), with no Calendar allocated. Before 1582-10-15, getMillis() and forMillis() agree with Instant, and forMillis() rejects the two days of millis below 0001-01-01; calendarValue() moves its cutover out of range for every date, so it no longer equals a default GregorianCalendar. Two Julian-value test expectations are corrected, and a new test pins the cutover dates as distinct instants; it fails on master. ./gradlew build passes.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

…ndar

Timestamp validated day-of-month with proleptic Gregorian leap rules but
converted to and from epoch milliseconds through java.util.Date, which
applies the Julian calendar before 1582-10-15. 1582-10-05T00:00:00Z and
1582-10-15T00:00:00Z shared one epoch millisecond, so compareTo called
them equal while equals did not, and every earlier date denoted an
instant days away from the one its text names (amazon-ion#165).

Both directions now use exact proleptic Gregorian day arithmetic, with
no Calendar allocated, which is the strategy amazon-ion#165 asked for after the
Calendar-backed attempt was reverted for heap usage. calendarValue()
moves the Julian cutover out of range so its fields agree. Two existing
expectations encoded the Julian value and are corrected; a new test pins
the cutover dates as distinct instants.
@lenamonj
lenamonj force-pushed the timestamp-proleptic-gregorian branch from 1c3b971 to de78754 Compare September 6, 2026 09:15

@toddjonker toddjonker left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have the spec considerations that I noted in 2018 been addressed?

How do other Ion libraries behave in similar circumstances?

Can we add coverage for this issue in the ion-tests suite so that any divergence between implementations are known?

Comment on lines +328 to +355
private static long epochDayFromCivil(int year, int month, int day)
{
long y = year - (month <= 2 ? 1 : 0);
long era = (y >= 0 ? y : y - 399) / 400;
long yearOfEra = y - era * 400; // [0, 399]
long dayOfYear = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1; // [0, 365]
long dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear;
return era * 146097 + dayOfEra - 719468;
}

/**
* Inverse of {@link #epochDayFromCivil(int, int, int)}; fills year, month and day.
*/
private static void civilFromEpochDay(long epochDay, int[] yearMonthDay)
{
long z = epochDay + 719468;
long era = (z >= 0 ? z : z - 146096) / 146097;
long dayOfEra = z - era * 146097; // [0, 146096]
long yearOfEra = (dayOfEra - dayOfEra / 1460 + dayOfEra / 36524 - dayOfEra / 146096) / 365;
long y = yearOfEra + era * 400;
long dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100);
long monthPrime = (5 * dayOfYear + 2) / 153; // [0, 11]
long day = dayOfYear - (153 * monthPrime + 2) / 5 + 1; // [1, 31]
long month = monthPrime + (monthPrime < 10 ? 3 : -9); // [1, 12]
yearMonthDay[0] = (int) (y + (month <= 2 ? 1 : 0));
yearMonthDay[1] = (int) month;
yearMonthDay[2] = (int) day;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is going to be effectively unmaintainable without a lot more documentation. Way too many magic numbers. Are we sure we cannot use JDK APIs to handle this math?

If not, I have to wonder whether this edge-case bug is worth fixing. Timestamp has been one of the most bug-prone components of the whole library, and this change smells of increasing brittleness, or at least of making future debugging more difficult.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be a little more clear: to the extent that I have any authority as original maintainer, I would reject this change as currently written, for the simple reason that I cannot read and understand the code. And if it could, I could not easily verify its correctness.

If you wish to continue, please:

  • Add links to documentation/explanation of the algorithm and math
  • Reduce the use of magic numbers
  • Do what you can to make it easy for future maintainers to understand, and to make it "obviously correct".

Thanks!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The conversion now goes through java.time (LocalDateTime, whose ISO chronology is proleptic Gregorian), so the hand-written arithmetic and its constants are gone and the Javadoc points at IsoChronology; each call builds a transient LocalDateTime, as master's Date.UTC builds a calendar date and a Date, and nothing is retained per Timestamp. For callers, millis before 1582-10-15 now agree with Instant both ways (master's forEpochSecond(i.getEpochSecond(), i.getNano(), 0), documented as equivalent to Instant i, turns 1582-10-05 into 1582-09-25), forMillis rejects the two days of millis master accepted before 0001-01-01, and calendarValue() has its cutover moved out of range, so it no longer equals a default GregorianCalendar. If that is still not worth it, fine to close.

The epoch conversion now goes through LocalDateTime, whose ISO chronology
is the proleptic Gregorian calendar, instead of hand-written day
arithmetic. Math.floorDiv replaces the local floorDiv, and requireByte,
no longer used, is removed.
@lenamonj

Copy link
Copy Markdown
Author

The spec still names no calendar, but ion-java already validates days by proleptic Gregorian rules (it rejects 0200-02-29, a Julian leap day), and this change makes getMillis() agree with that and with Instant, one of the replacements your 2018 note named; deprecating the millis APIs is separate and still open. ion-js, ion-dotnet, ion-python, ion-rust and ion-go use proleptic Gregorian types (Date.UTC, DateTime, datetime, NaiveDateTime, time.Time); ion-c converts to time_t through timegm off Windows, which its own source notes fails before the Unix epoch. ion-tests cannot catch this today: equivs and non-equivs compare fields via Timestamp.equals, never the instant, and equivTimeline only asserts equality; I can propose a distinct-instant category there if you want it.

@codecov

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 67.93%. Comparing base (3c1b6b1) to head (9d438fd).
⚠️ Report is 157 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff              @@
##             master    #1165      +/-   ##
============================================
+ Coverage     67.23%   67.93%   +0.69%     
- Complexity     5484     5666     +182     
============================================
  Files           159      160       +1     
  Lines         23025    23359     +334     
  Branches       4126     4203      +77     
============================================
+ Hits          15481    15868     +387     
+ Misses         6262     6190      -72     
- Partials       1282     1301      +19     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants