Skip to content

Instantly share code, notes, and snippets.

@not-napoleon
Last active August 30, 2021 21:07
Show Gist options
  • Select an option

  • Save not-napoleon/7b3adbea80f3fafe16ebae001586a763 to your computer and use it in GitHub Desktop.

Select an option

Save not-napoleon/7b3adbea80f3fafe16ebae001586a763 to your computer and use it in GitHub Desktop.
import org.junit.Test;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.SignStyle;
import java.time.temporal.ChronoField;
import java.time.temporal.TemporalAccessor;
import java.util.Locale;
import static java.time.temporal.ChronoField.DAY_OF_MONTH;
import static java.time.temporal.ChronoField.HOUR_OF_DAY;
import static java.time.temporal.ChronoField.MINUTE_OF_HOUR;
import static java.time.temporal.ChronoField.MONTH_OF_YEAR;
import static java.time.temporal.ChronoField.NANO_OF_SECOND;
import static java.time.temporal.ChronoField.SECOND_OF_MINUTE;
import static org.junit.Assert.assertEquals;
public class DateParseTests {
/**
* This test shows the issue. We have a string in unambiguous seconds since epoch. We parse this
* and it resolves to an ambiguous time in America/New_York, which then gets represented as a different
* seconds since epoch value.
*
* The formatting step isn't necessary as part of the test, the error happens in the parse. Based on
* tracing this in the debugger, it seems like the problem occurs in java.time.format.Parsed#resolve()
* which correctly parses an unambiguous seconds value, which it then converts to a date + time + timezone
* value, which is ambiguous. Then a later step converts that back to a seconds since epoch, but picks
* the wrong one
*/
@Test
public void testEpochSecondsWithDSTEnd() {
final DateTimeFormatter epochSecondFormatter = new DateTimeFormatterBuilder()
.appendValue(ChronoField.INSTANT_SECONDS, 1, 19, SignStyle.NORMAL)
.toFormatter(Locale.ROOT)
.withZone(ZoneId.of("America/New_York"));
// This is the transition point from DST to Standard time for that year in America/New_York tz
ZonedDateTime sixAm = ZonedDateTime.of(2020, 11, 1, 6, 0, 0, 0, ZoneOffset.UTC);
assertEquals(1604210400000L, sixAm.toInstant().toEpochMilli());
assertEquals("1604210400", epochSecondFormatter.format(sixAm));
TemporalAccessor actual = epochSecondFormatter.parse(epochSecondFormatter.format(sixAm));
/*
java.lang.AssertionError:
Expected :1604210400
Actual :1604206800
*/
assertEquals(sixAm.toEpochSecond(), actual.getLong(ChronoField.INSTANT_SECONDS));
}
/**
* This shows that it parses fine when there isn't a DST to Standard transition
*/
@Test
public void testEpochSecondsWithTimeZone() {
final DateTimeFormatter epochSecondFormatter = new DateTimeFormatterBuilder()
.appendValue(ChronoField.INSTANT_SECONDS, 1, 19, SignStyle.NORMAL)
.toFormatter(Locale.ROOT)
.withZone(ZoneId.of("America/New_York"));
ZonedDateTime sixAm = ZonedDateTime.of(2020, 10, 1, 6, 0, 0, 0, ZoneOffset.UTC);
TemporalAccessor actual = epochSecondFormatter.parse(epochSecondFormatter.format(sixAm));
// Passes
assertEquals(sixAm.toEpochSecond(), actual.getLong(ChronoField.INSTANT_SECONDS));
}
/**
* This test does the same thing, but with a more elaborate format. We still serialize an
* unambiguous time, parse it, and spit out a new value. But in this case, we get the correct
* result.
*/
@Test
public void testIsoFormatDstEnd() {
final DateTimeFormatter isoFormatter = new DateTimeFormatterBuilder()
.appendValue(ChronoField.YEAR, 4, 4, SignStyle.EXCEEDS_PAD)
.optionalStart()
.appendLiteral("-")
.appendValue(MONTH_OF_YEAR, 2, 2, SignStyle.NOT_NEGATIVE)
.optionalStart()
.appendLiteral('-')
.appendValue(DAY_OF_MONTH, 2, 2, SignStyle.NOT_NEGATIVE)
.optionalEnd()
.optionalEnd()
.appendLiteral('T')
.optionalStart()
.appendValue(HOUR_OF_DAY, 2, 2, SignStyle.NOT_NEGATIVE)
.optionalStart()
.appendLiteral(':')
.appendValue(MINUTE_OF_HOUR, 2, 2, SignStyle.NOT_NEGATIVE)
.optionalStart()
.appendLiteral(':')
.appendValue(SECOND_OF_MINUTE, 2, 2, SignStyle.NOT_NEGATIVE)
.optionalStart()
.appendFraction(NANO_OF_SECOND, 1, 9, true)
.optionalEnd()
.optionalStart()
.appendLiteral(",")
.appendFraction(NANO_OF_SECOND, 1, 9, false)
.optionalEnd()
.optionalEnd()
.optionalEnd()
.optionalEnd()
.optionalStart()
.appendZoneOrOffsetId()
.optionalEnd()
.optionalStart()
.appendOffset("+HHmm", "Z")
.optionalEnd()
.toFormatter(Locale.ROOT)
.withZone(ZoneId.of("America/New_York"));
// This is the transition point from DST to Standard time for that year in America/New_York tz
ZonedDateTime sixAm = ZonedDateTime.of(2020, 11, 1, 6, 0, 0, 0, ZoneOffset.UTC);
assertEquals(1604210400000L, sixAm.toInstant().toEpochMilli());
assertEquals("2020-11-01T01:00:00.0,0America/New_York-05", isoFormatter.format(sixAm));
TemporalAccessor actual = isoFormatter.parse(isoFormatter.format(sixAm));
assertEquals(sixAm.toEpochSecond(), actual.getLong(ChronoField.INSTANT_SECONDS));
}
}
@not-napoleon

Copy link
Copy Markdown
Author

We submitted this as a bug, and the JDK team fixed it: https://bugs.openjdk.java.net/browse/JDK-8272473

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