-
Notifications
You must be signed in to change notification settings - Fork 25k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
support negative epoch_millis timestamps #80208
Changes from 7 commits
6fce9cd
a30f83a
c699886
c849b93
f225fcf
96aeb31
926bc49
7919af4
bcf2293
dda79f3
9cbae7f
66d1ec6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -28,13 +28,14 @@ | |
* The seconds formatter is provided by {@link #SECONDS_FORMATTER}. | ||
* The milliseconds formatter is provided by {@link #MILLIS_FORMATTER}. | ||
* <p> | ||
* Both formatters support fractional time, up to nanosecond precision. Values must be positive numbers. | ||
* Both formatters support fractional time, up to nanosecond precision. | ||
*/ | ||
class EpochTime { | ||
|
||
private static final ValueRange LONG_POSITIVE_RANGE = ValueRange.of(0, Long.MAX_VALUE); | ||
private static final ValueRange LONG_INTEGER_RANGE = ValueRange.of(Long.MIN_VALUE, Long.MAX_VALUE); | ||
private static final ValueRange POSITIVE_LONG_INTEGER_RANGE = ValueRange.of(0, Long.MAX_VALUE); | ||
|
||
private static final EpochField SECONDS = new EpochField(ChronoUnit.SECONDS, ChronoUnit.FOREVER, LONG_POSITIVE_RANGE) { | ||
private static final EpochField SECONDS = new EpochField(ChronoUnit.SECONDS, ChronoUnit.FOREVER, LONG_INTEGER_RANGE) { | ||
@Override | ||
public boolean isSupportedBy(TemporalAccessor temporal) { | ||
return temporal.isSupported(ChronoField.INSTANT_SECONDS); | ||
|
@@ -61,6 +62,24 @@ public TemporalAccessor resolve( | |
} | ||
}; | ||
|
||
// TemporalField is only present in the presence of a negative (potentially fractional) timestamp. | ||
private static final long NEGATIVE_SIGN_PLACEHOLDER = -1; | ||
private static final EpochField NEGATIVE_SIGN_FIELD = new EpochField( | ||
ChronoUnit.FOREVER, | ||
ChronoUnit.FOREVER, | ||
ValueRange.of(NEGATIVE_SIGN_PLACEHOLDER, NEGATIVE_SIGN_PLACEHOLDER) | ||
) { | ||
@Override | ||
public boolean isSupportedBy(TemporalAccessor temporal) { | ||
return temporal.isSupported(ChronoField.INSTANT_SECONDS) && temporal.getLong(ChronoField.INSTANT_SECONDS) < 0; | ||
} | ||
|
||
@Override | ||
public long getFrom(TemporalAccessor temporal) { | ||
return NEGATIVE_SIGN_PLACEHOLDER; | ||
} | ||
}; | ||
|
||
private static final EpochField NANOS_OF_SECOND = new EpochField(ChronoUnit.NANOS, ChronoUnit.SECONDS, ValueRange.of(0, 999_999_999)) { | ||
@Override | ||
public boolean isSupportedBy(TemporalAccessor temporal) { | ||
|
@@ -73,15 +92,30 @@ public long getFrom(TemporalAccessor temporal) { | |
} | ||
}; | ||
|
||
private static final EpochField MILLIS = new EpochField(ChronoUnit.MILLIS, ChronoUnit.FOREVER, LONG_POSITIVE_RANGE) { | ||
private static final EpochField UNSIGNED_MILLIS = new EpochField(ChronoUnit.MILLIS, ChronoUnit.FOREVER, POSITIVE_LONG_INTEGER_RANGE) { | ||
@Override | ||
public boolean isSupportedBy(TemporalAccessor temporal) { | ||
return temporal.isSupported(ChronoField.INSTANT_SECONDS) && temporal.isSupported(ChronoField.MILLI_OF_SECOND); | ||
return temporal.isSupported(ChronoField.INSTANT_SECONDS) | ||
&& (temporal.isSupported(ChronoField.NANO_OF_SECOND) || temporal.isSupported(ChronoField.MILLI_OF_SECOND)); | ||
} | ||
|
||
@Override | ||
public long getFrom(TemporalAccessor temporal) { | ||
return temporal.getLong(ChronoField.INSTANT_SECONDS) * 1_000 + temporal.getLong(ChronoField.MILLI_OF_SECOND); | ||
long millis = temporal.getLong(ChronoField.INSTANT_SECONDS) * 1_000; | ||
if (millis >= 0 || temporal.isSupported(ChronoField.NANO_OF_SECOND) == false) { | ||
return millis + temporal.getLong(ChronoField.MILLI_OF_SECOND); | ||
} else { | ||
long nanos = temporal.getLong(ChronoField.NANO_OF_SECOND); | ||
if (nanos % 1_000_000 != 0) { | ||
// Fractional negative timestamp. | ||
// Add 1 ms towards positive infinity because the fraction leads | ||
// the output's integral part to be an off-by-one when the | ||
// `(nanos / 1_000_000)` is added below. | ||
millis += 1; | ||
} | ||
millis += (nanos / 1_000_000); | ||
return -millis; // positive for formatting; sign handled by NEGATIVE_SIGN_FIELD | ||
} | ||
} | ||
|
||
@Override | ||
|
@@ -90,19 +124,47 @@ public TemporalAccessor resolve( | |
TemporalAccessor partialTemporal, | ||
ResolverStyle resolverStyle | ||
) { | ||
long secondsAndMillis = fieldValues.remove(this); | ||
long seconds = secondsAndMillis / 1_000; | ||
long nanos = secondsAndMillis % 1000 * 1_000_000; | ||
Long isNegative = fieldValues.remove(NEGATIVE_SIGN_FIELD); | ||
Long nanosOfMilli = fieldValues.remove(NANOS_OF_MILLI); | ||
if (nanosOfMilli != null) { | ||
nanos += nanosOfMilli; | ||
long secondsAndMillis = fieldValues.remove(this); | ||
|
||
long seconds; | ||
long nanos; | ||
if (isNegative != null) { | ||
secondsAndMillis = -secondsAndMillis; | ||
seconds = secondsAndMillis / 1_000; | ||
nanos = secondsAndMillis % 1000 * 1_000_000; | ||
// `secondsAndMillis < 0` implies negative timestamp; so `nanos < 0` | ||
if (nanosOfMilli != null) { | ||
// aggregate fractional part of the input; subtract b/c `nanos < 0` | ||
nanos -= nanosOfMilli; | ||
} | ||
if (nanos != 0) { | ||
// nanos must be positive. B/c the timestamp is represented by the | ||
// (seconds, nanos) tuple, seconds moves 1s toward negative-infinity | ||
// and nanos moves 1s toward positive-infinity | ||
seconds -= 1; | ||
nanos = 1_000_000_000 + nanos; | ||
} | ||
} else { | ||
seconds = secondsAndMillis / 1_000; | ||
nanos = secondsAndMillis % 1000 * 1_000_000; | ||
|
||
if (nanosOfMilli != null) { | ||
// aggregate fractional part of the input | ||
nanos += nanosOfMilli; | ||
} | ||
} | ||
|
||
fieldValues.put(ChronoField.INSTANT_SECONDS, seconds); | ||
fieldValues.put(ChronoField.NANO_OF_SECOND, nanos); | ||
// if there is already a milli of second, we need to overwrite it | ||
if (fieldValues.containsKey(ChronoField.MILLI_OF_SECOND)) { | ||
fieldValues.put(ChronoField.MILLI_OF_SECOND, nanos / 1_000_000); | ||
} | ||
if (fieldValues.containsKey(ChronoField.MICRO_OF_SECOND)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is this needed? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is not needed. I added it defensively since the same logic is happening above with |
||
fieldValues.put(ChronoField.MICRO_OF_SECOND, nanos / 1_000); | ||
} | ||
return null; | ||
} | ||
}; | ||
|
@@ -117,7 +179,11 @@ public boolean isSupportedBy(TemporalAccessor temporal) { | |
|
||
@Override | ||
public long getFrom(TemporalAccessor temporal) { | ||
return temporal.getLong(ChronoField.NANO_OF_SECOND) % 1_000_000; | ||
if (temporal.getLong(ChronoField.INSTANT_SECONDS) < 0) { | ||
return (1_000_000_000 - temporal.getLong(ChronoField.NANO_OF_SECOND)) % 1_000_000; | ||
} else { | ||
return temporal.getLong(ChronoField.NANO_OF_SECOND) % 1_000_000; | ||
} | ||
} | ||
}; | ||
|
||
|
@@ -133,13 +199,15 @@ public long getFrom(TemporalAccessor temporal) { | |
.appendLiteral('.') | ||
.toFormatter(Locale.ROOT); | ||
|
||
// this supports milliseconds without any fraction | ||
private static final DateTimeFormatter MILLISECONDS_FORMATTER1 = new DateTimeFormatterBuilder().appendValue( | ||
MILLIS, | ||
1, | ||
19, | ||
SignStyle.NORMAL | ||
).optionalStart().appendFraction(NANOS_OF_MILLI, 0, 6, true).optionalEnd().toFormatter(Locale.ROOT); | ||
// this supports milliseconds | ||
public static final DateTimeFormatter MILLISECONDS_FORMATTER1 = new DateTimeFormatterBuilder().optionalStart() | ||
.appendText(NEGATIVE_SIGN_FIELD, Map.of(-1L, "-")) // field is only created in the presence of a '-' char. | ||
.optionalEnd() | ||
.appendValue(UNSIGNED_MILLIS, 1, 19, SignStyle.NOT_NEGATIVE) | ||
.optionalStart() | ||
.appendFraction(NANOS_OF_MILLI, 0, 6, true) | ||
.optionalEnd() | ||
.toFormatter(Locale.ROOT); | ||
|
||
// this supports milliseconds ending in dot | ||
private static final DateTimeFormatter MILLISECONDS_FORMATTER2 = new DateTimeFormatterBuilder().append(MILLISECONDS_FORMATTER1) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This adjustment is tricky, and I had to stare at it for a while to understand. I think the comment would be more helpful if it mentions rounding. Essentially there is some extra precision that we are going to throw away by converting this to millis, and this adjustment by 1 ensures we are always rounding towards the epoch. Is that right? If so, can you improve the comment a bit?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Your understanding is correct. If we get into this branch, it means two things: 1) we're dealing with a negative epoch, 2) we're dealing with a fractional millisecond. Thus, the calculated
millis
invokes a precision loss that needs to be rounded towards the epoch for formatting (i.e. to a smaller magnitude for formatting, since Java date-time stores it as a negative at a higher magnitude by 1)I'll update the comment to reflect.