fix: ISODate.parseISODate no longer corrupts Feb 29th into March 1st - #779
Conversation
The year-less nearest-year branch mutated a single shared Calendar in sequence to compute previous/current/next-year candidates. Calendar field mutations are lazy and only normalize on getTime()/get(), and that normalization writes back into the Calendar's own field array - so when MM/DD is Feb 29th and the real current year isn't a leap year, the first candidate's rollover to March 1st corrupted the shared Calendar, and every later candidate built on that corrupted state instead of the original Feb 29th. All three candidates could come back March 1st, silently, even when a genuine leap year was one year away. Each candidate is now computed independently by re-asserting YEAR/MONTH/DATE before every getTime() call, and a genuine date is preferred over one that only exists via rollover, so the nearest real Feb 29th wins over a chronologically-closer rollover. Fixes jpos#778.
ar-agt
left a comment
There was a problem hiding this comment.
@alcarraz The leap-day cases are fixed, but I reproduced a new DST regression on this head that should be addressed before merging.
buildDate() restores only YEAR/MONTH/DATE. Normalizing a candidate in a DST gap also changes HOUR_OF_DAY on the shared Calendar, and that changed hour carries into later candidates.
Reproduction
ZoneId zone = ZoneId.of("America/New_York");
long now = ZonedDateTime.of(2026, 3, 9, 12, 0, 0, 0, zone)
.toInstant().toEpochMilli();
Date result = ISODate.parseISODate("0309023000", now, TimeZone.getTimeZone(zone));
System.out.println(result.toInstant().atZone(zone));- Expected and unchanged main (
91c3ac10d):2026-03-09T02:30-04:00[America/New_York] - This PR (
ef72bc834):2026-03-09T03:30-04:00[America/New_York]
The previous-year candidate, March 9, 2025 at 02:30, falls in a DST gap. Its normalization changes the shared hour to 03:30, contaminating the valid 2026 candidate.
Please capture the reference year before setting the input fields and initialize every candidate from a clean state using all original date/time fields. Reusing one Calendar with clear() and complete field initialization is an option; separate instances are another. The validity calculation should likewise not depend on fields left behind by candidate normalization.
Please add this DST regression with an explicit time zone and a full date/time assertion, plus a case where the valid leap day is in the next candidate year. The added leap-day cases currently cover previous/current leap years and the no-leap fallback, not the next-year case.
Verification: all 21 ISODateTest tests passed locally using the pinned Java 26.0.2 / Gradle 9.7.1 toolchain. A separate harness compiled against the PR and unchanged main reproduced the different outputs above. I did not rerun the full suite.
The prior commit's buildDate() only re-asserted YEAR/MONTH/DATE on the reused Calendar, assuming a date rollover never touches the time-of-day fields. That assumption breaks for DST gaps: normalizing a candidate whose local time falls in a DST spring-forward gap also rewrites HOUR_OF_DAY, and that leaked into the next candidate built on the same Calendar. buildDate() now clears and fully re-sets every field per candidate, and the day-validity check moved out of Calendar entirely (plain Gregorian leap-year arithmetic via java.time.Year.isLeap) so it can no longer be skewed by state left behind by a candidate's own normalization. Also fixes an out-of-bounds array access: isValidDay() indexed DAYS_IN_MONTH by month without checking it was in range, so a malformed 2-digit month (e.g. "00" or "13"+) in a year-less MMDDhhmmss field threw ArrayIndexOutOfBoundsException instead of falling back to the old lenient-rollover behavior like every other malformed input. Adds regression tests for the DST leak, the out-of-range month cases, and a leap-year candidate in the next (rather than previous/current) year.
|
Fixed. Also found and fixed a related bug: the validity check indexed a days-in-month array by month without bounds-checking, so a malformed month ( Added tests for your DST repro, a next-year leap candidate, and both out-of-range month cases. All pass, |
|
I'd missed the first point of your suggested correction: the reference year was still being read via the same shared Rather than just reordering the one read, the last commit splits the shared While in there, the three year-less candidates and their comparison now carry plain epoch millis instead of Added regression tests for out-of-range months (too small and too large), confirming the computed reference year now matches the real current year rather than one shifted by the input's own rollover. |
Capturing the reference year via the same Calendar that later has the input's MM/DD/hh/mm/ss written onto it meant a rollover in those input fields (e.g. an out-of-range month) could shift the computed "current year" before it was ever read -- the exact ordering hazard the DST fix was about, just on a different field. Each branch (explicit year, year-less) now gets its own freshly-scoped Calendar, so nothing set in one branch can affect a read in another. Also: isValidDay() now defers to java.time.YearMonth.lengthOfMonth() instead of a hand-rolled leap-year/days-in-month table; the year-less branch's three candidates and their comparison now carry plain epoch millis instead of Date objects until the winning candidate is chosen, cutting two throwaway Date allocations; and the helper that used to be misnamed buildDate() (it returns millis, not a Date) is now toEpochMillis(). No behavior change beyond correctly anchoring the reference year -- verified against the full test suite plus ISODateTest. Signed-off-by: Andrés Alcarraz <andres.alcarraz@transactility.com>
1aab3ed to
8089998
Compare
Fixes #778.
The Problem
parseISODate(String, long, TimeZone)'s year-less branch resolves aMMDDhhmmssfield into the nearest of three candidate years (previous/current/next) by mutating a single sharedCalendarin sequence:Calendarfield mutations are lazy:set()doesn't normalize anything by itself. Normalization only happens ongetTime()/get(), and when it does, it writes the normalized values back into theCalendar's own field array. So whenMM/DD = "0229"and the real current year isn't a leap year, the firstthisYear = cal.getTime()call silently rolls Feb 29th over to March 1st and mutatescalin place. Every latercal.set(YEAR, ...)forpreviousYear/nextYearthen builds on that already-corruptedMarch 1ststate instead of the originalFeb 29th— so even a genuinely valid leap-year candidate among the three never gets a chance to be computed correctly. All three candidates can come back March 1st, silently, with no error.The Fix
Each of the three candidate years is now computed independently, and a genuine date is preferred over one that only exists via rollover.
buildDate(cal, year, month, day)re-assertsYEAR/MONTH/DATE— the only fields a date rollover can touch — before everygetTime()call, so one candidate's rollover can no longer leak into the next.It reuses the existing
calinstance rather than allocating a freshCalendarper candidate.parseISODatesits on a hot path — field 7/12/13 date parsing happens on essentially every ISO-8583 message — and a naive per-candidate fix (a brand-newCalendarfor each of the three years,clear()ed to guarantee no shared state) would add 3-6 extra allocations per call versus the original's single shared instance. Reusingcalkeeps allocation count unchanged from before the fix, and it's still safe:HOUR_OF_DAY/MINUTE/SECOND/MILLISECONDare set once above the branch and a date-only rollover never touches them, so re-asserting onlyYEAR/MONTH/DATEper candidate is sufficient to erase any state left over from the previous candidate'sgetTime()call.diff(cal, now, candidate, year, month, day)computes the distance tonowas before, but adds a large penalty whendaydoesn't actually exist inmonth/year(checked viaCalendar.getActualMaximum(DATE)). This makes a real Feb 29th win the nearest-year comparison over a chronologically-closer-but-rolled-over March 1st, which is what the original bug reports as the expected behavior. The penalty is the same constant for all three candidates, so when none of the three years is a leap year, it cancels out of the comparison and the method falls back to the old rollover-based nearest-year result — the return contract (always a non-nullDate) is unchanged, since library callers with no null-check shouldn't see new NPEs.The 3-way candidate selection itself (
if (previousDiff < thisDiff) ... if (thisDiff > nextDiff) ...) keeps the same shape as the original code, just fed by the correcteddiffvalues.Testing
Added to
ISODateTest:nullfor backward compatibility../gradlew jpos:test(full suite) passes.