Skip to content

Commit ee163eb

Browse files
authored
Epoch Millis Rounding Down and Not Up (#118353)
fixed an issue where epoch millis were not being rounded up but instead were rounded down causing gt behavior to fail when off by 1 millisecond
1 parent 84f233a commit ee163eb

File tree

5 files changed

+429
-25
lines changed

5 files changed

+429
-25
lines changed

docs/changelog/118353.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pr: 118353
2+
summary: Epoch Millis Rounding Down and Not Up 2
3+
area: Infra/Core
4+
type: bug
5+
issues: []

server/src/main/java/org/elasticsearch/common/time/EpochTime.java

Lines changed: 85 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,25 @@ class EpochTime {
3535

3636
private static final ValueRange POSITIVE_LONG_INTEGER_RANGE = ValueRange.of(0, Long.MAX_VALUE);
3737

38+
// TemporalField is only present in the presence of a rounded timestamp
39+
private static final long ROUNDED_SIGN_PLACEHOLDER = -2;
40+
private static final EpochField ROUNDED_SIGN_FIELD = new EpochField(
41+
ChronoUnit.FOREVER,
42+
ChronoUnit.FOREVER,
43+
ValueRange.of(ROUNDED_SIGN_PLACEHOLDER, ROUNDED_SIGN_PLACEHOLDER)
44+
) {
45+
// FIXME: what should this be?
46+
@Override
47+
public boolean isSupportedBy(TemporalAccessor temporal) {
48+
return temporal.isSupported(ChronoField.INSTANT_SECONDS) && temporal.getLong(ChronoField.INSTANT_SECONDS) < 0;
49+
}
50+
51+
@Override
52+
public long getFrom(TemporalAccessor temporal) {
53+
return ROUNDED_SIGN_PLACEHOLDER;
54+
}
55+
};
56+
3857
// TemporalField is only present in the presence of a negative (potentially fractional) timestamp.
3958
private static final long NEGATIVE_SIGN_PLACEHOLDER = -1;
4059
private static final EpochField NEGATIVE_SIGN_FIELD = new EpochField(
@@ -161,6 +180,10 @@ public TemporalAccessor resolve(
161180
Long nanosOfMilli = fieldValues.remove(NANOS_OF_MILLI);
162181
long secondsAndMillis = fieldValues.remove(this);
163182

183+
// this flag indicates whether we were asked to round up and we defaulted to 999_999 nanos or nanos were given by the users
184+
// specifically we do not wnat to confuse defaulted 999_999 nanos with user supplied 999_999 nanos
185+
boolean roundUp = fieldValues.remove(ROUNDED_SIGN_FIELD) != null;
186+
164187
long seconds;
165188
long nanos;
166189
if (isNegative != null) {
@@ -169,10 +192,18 @@ public TemporalAccessor resolve(
169192
nanos = secondsAndMillis % 1000 * 1_000_000;
170193
// `secondsAndMillis < 0` implies negative timestamp; so `nanos < 0`
171194
if (nanosOfMilli != null) {
172-
// aggregate fractional part of the input; subtract b/c `nanos < 0`
173-
nanos -= nanosOfMilli;
195+
if (roundUp) {
196+
// these are not the nanos you think they are; these are "round up nanos" not the fractional part of the input
197+
// this is the case where we defaulted the value to 999_999 and the intention for rounding is that the value
198+
// moves closer to positive infinity
199+
nanos += nanosOfMilli;
200+
} else {
201+
// aggregate fractional part of the input; subtract b/c `nanos < 0`
202+
// this is the case where the user has supplied a nanos value and we'll want to shift toward negative infinity
203+
nanos -= nanosOfMilli;
204+
}
174205
}
175-
if (nanos != 0) {
206+
if (nanos < 0) {
176207
// nanos must be positive. B/c the timestamp is represented by the
177208
// (seconds, nanos) tuple, seconds moves 1s toward negative-infinity
178209
// and nanos moves 1s toward positive-infinity
@@ -235,38 +266,70 @@ public long getFrom(TemporalAccessor temporal) {
235266
.appendLiteral('.')
236267
.toFormatter(Locale.ROOT);
237268

238-
// this supports milliseconds
239-
public static final DateTimeFormatter MILLISECONDS_FORMATTER1 = new DateTimeFormatterBuilder().optionalStart()
269+
static final DateFormatter SECONDS_FORMATTER = new JavaDateFormatter(
270+
"epoch_second",
271+
new JavaTimeDateTimePrinter(SECONDS_FORMATTER1),
272+
JavaTimeDateTimeParser.createRoundUpParserGenerator(builder -> builder.parseDefaulting(ChronoField.NANO_OF_SECOND, 999_999_999L)),
273+
new JavaTimeDateTimeParser(SECONDS_FORMATTER1),
274+
new JavaTimeDateTimeParser(SECONDS_FORMATTER2)
275+
);
276+
277+
public static final DateTimeFormatter MILLISECONDS_FORMATTER_BASE = new DateTimeFormatterBuilder().optionalStart()
240278
.appendText(NEGATIVE_SIGN_FIELD, Map.of(-1L, "-")) // field is only created in the presence of a '-' char.
241279
.optionalEnd()
242280
.appendValue(UNSIGNED_MILLIS, 1, 19, SignStyle.NOT_NEGATIVE)
281+
.toFormatter(Locale.ROOT);
282+
283+
// FIXME: clean these up and append one to the other
284+
// this supports milliseconds
285+
public static final DateTimeFormatter MILLISECONDS_FORMATTER = new DateTimeFormatterBuilder().append(MILLISECONDS_FORMATTER_BASE)
243286
.optionalStart()
244287
.appendFraction(NANOS_OF_MILLI, 0, 6, true)
245288
.optionalEnd()
246289
.toFormatter(Locale.ROOT);
247290

248-
// this supports milliseconds ending in dot
249-
private static final DateTimeFormatter MILLISECONDS_FORMATTER2 = new DateTimeFormatterBuilder().optionalStart()
250-
.appendText(NEGATIVE_SIGN_FIELD, Map.of(-1L, "-")) // field is only created in the presence of a '-' char.
251-
.optionalEnd()
252-
.appendValue(UNSIGNED_MILLIS, 1, 19, SignStyle.NOT_NEGATIVE)
253-
.appendLiteral('.')
291+
// this supports milliseconds
292+
public static final DateTimeFormatter MILLISECONDS_PARSER_W_NANOS = new DateTimeFormatterBuilder().append(MILLISECONDS_FORMATTER_BASE)
293+
.appendFraction(NANOS_OF_MILLI, 0, 6, true)
254294
.toFormatter(Locale.ROOT);
255295

256-
static final DateFormatter SECONDS_FORMATTER = new JavaDateFormatter(
257-
"epoch_second",
258-
new JavaTimeDateTimePrinter(SECONDS_FORMATTER1),
259-
JavaTimeDateTimeParser.createRoundUpParserGenerator(builder -> builder.parseDefaulting(ChronoField.NANO_OF_SECOND, 999_999_999L)),
260-
new JavaTimeDateTimeParser(SECONDS_FORMATTER1),
261-
new JavaTimeDateTimeParser(SECONDS_FORMATTER2)
262-
);
296+
// we need an additional parser to detect the difference between user provided nanos and defaulted ones because of the necessity
297+
// to parse the two differently in the round up case
298+
public static final DateTimeFormatter MILLISECONDS_PARSER_WO_NANOS = new DateTimeFormatterBuilder().append(MILLISECONDS_FORMATTER_BASE)
299+
.toFormatter(Locale.ROOT);
263300

301+
// we need an additional parser to detect the difference between user provided nanos and defaulted ones because of the necessity
302+
// to parse the two differently in the round up case
303+
public static final DateTimeFormatter MILLISECONDS_PARSER_WO_NANOS_ROUNDING = new DateTimeFormatterBuilder().append(
304+
MILLISECONDS_FORMATTER_BASE
305+
).parseDefaulting(EpochTime.ROUNDED_SIGN_FIELD, -2L).parseDefaulting(EpochTime.NANOS_OF_MILLI, 999_999L).toFormatter(Locale.ROOT);
306+
307+
// this supports milliseconds ending in dot
308+
private static final DateTimeFormatter MILLISECONDS_PARSER_ENDING_IN_PERIOD = new DateTimeFormatterBuilder().append(
309+
MILLISECONDS_FORMATTER_BASE
310+
).appendLiteral('.').toFormatter(Locale.ROOT);
311+
312+
/*
313+
We separately handle the rounded and non-rounded uses cases here with different parsers. The reason is because of how we store and
314+
handle negative milliseconds since the epoch. If a user supplies nanoseconds as part of a negative millisecond since epoch value
315+
then we need to round toward negative infinity. However, in the case where nanos are not supplied, and we are requested to
316+
round up we will default the value of nanos to 999_999 and need to delineate that this rounding was intended to push the value
317+
toward positive infinity not negative infinity. Differentiating these two cases during parsing requires a flag called out above
318+
the ROUNDED_SIGN_FIELD flag. In addition to this flag we need to know that we are in the "rounding up" state. So any time we are
319+
asked to round up we will force setting the ROUNDED_SIGN_FIELD flag and be able to detect that when parsing and
320+
storing the time information and be able to make the correct decision to round toward positive infinity.
321+
*/
264322
static final DateFormatter MILLIS_FORMATTER = new JavaDateFormatter(
265323
"epoch_millis",
266-
new JavaTimeDateTimePrinter(MILLISECONDS_FORMATTER1),
267-
JavaTimeDateTimeParser.createRoundUpParserGenerator(builder -> builder.parseDefaulting(EpochTime.NANOS_OF_MILLI, 999_999L)),
268-
new JavaTimeDateTimeParser(MILLISECONDS_FORMATTER1),
269-
new JavaTimeDateTimeParser(MILLISECONDS_FORMATTER2)
324+
new JavaTimeDateTimePrinter(MILLISECONDS_FORMATTER),
325+
new JavaTimeDateTimeParser[] {
326+
new JavaTimeDateTimeParser(MILLISECONDS_PARSER_WO_NANOS_ROUNDING),
327+
new JavaTimeDateTimeParser(MILLISECONDS_PARSER_W_NANOS),
328+
new JavaTimeDateTimeParser(MILLISECONDS_PARSER_ENDING_IN_PERIOD) },
329+
new JavaTimeDateTimeParser[] {
330+
new JavaTimeDateTimeParser(MILLISECONDS_PARSER_WO_NANOS),
331+
new JavaTimeDateTimeParser(MILLISECONDS_PARSER_W_NANOS),
332+
new JavaTimeDateTimeParser(MILLISECONDS_PARSER_ENDING_IN_PERIOD) }
270333
);
271334

272335
private abstract static class EpochField implements TemporalField {

server/src/main/java/org/elasticsearch/common/time/JavaDateFormatter.java

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,11 +165,40 @@ static DateFormatter combined(String input, List<DateFormatter> formatters) {
165165
input,
166166
printer,
167167
roundUpParsers.stream().flatMap(Arrays::stream).toArray(DateTimeParser[]::new),
168-
parsers.stream().flatMap(Arrays::stream).toArray(DateTimeParser[]::new)
168+
parsers.stream().flatMap(Arrays::stream).toArray(DateTimeParser[]::new),
169+
false
169170
);
170171
}
171172

172-
private JavaDateFormatter(String format, DateTimePrinter printer, DateTimeParser[] roundupParsers, DateTimeParser[] parsers) {
173+
JavaDateFormatter(String format, DateTimePrinter printer, DateTimeParser[] roundupParsers, DateTimeParser[] parsers) {
174+
this(
175+
format,
176+
printer,
177+
Arrays.copyOf(roundupParsers, roundupParsers.length, DateTimeParser[].class),
178+
Arrays.copyOf(parsers, parsers.length, DateTimeParser[].class),
179+
true
180+
);
181+
}
182+
183+
private JavaDateFormatter(
184+
String format,
185+
DateTimePrinter printer,
186+
DateTimeParser[] roundupParsers,
187+
DateTimeParser[] parsers,
188+
boolean doValidate
189+
) {
190+
if (doValidate) {
191+
if (format.contains("||")) {
192+
throw new IllegalArgumentException("This class cannot handle multiple format specifiers");
193+
}
194+
if (printer == null) {
195+
throw new IllegalArgumentException("printer may not be null");
196+
}
197+
if (parsers.length == 0) {
198+
throw new IllegalArgumentException("parsers need to be specified");
199+
}
200+
verifyPrinterParsers(printer, parsers);
201+
}
173202
this.format = format;
174203
this.printer = printer;
175204
this.roundupParsers = roundupParsers;
@@ -247,7 +276,8 @@ private JavaDateFormatter mapParsers(UnaryOperator<DateTimePrinter> printerMappi
247276
format,
248277
printerMapping.apply(printer),
249278
mapParsers(parserMapping, this.roundupParsers),
250-
mapParsers(parserMapping, this.parsers)
279+
mapParsers(parserMapping, this.parsers),
280+
false
251281
);
252282
}
253283

server/src/test/java/org/elasticsearch/common/time/DateFormattersTests.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,21 @@ public void testEpochMillisParser() {
271271
IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> formatter.parse("12345.0."));
272272
assertThat(e.getMessage(), is("failed to parse date field [12345.0.] with format [epoch_millis]"));
273273
}
274+
{
275+
Instant instant = Instant.from(formatter.parse("-86400000"));
276+
assertThat(instant.getEpochSecond(), is(-86400L));
277+
assertThat(instant.getNano(), is(0));
278+
assertThat(formatter.format(instant), is("-86400000"));
279+
assertThat(Instant.from(formatter.parse(formatter.format(instant))), is(instant));
280+
}
281+
{
282+
Instant instant = Instant.from(formatter.parse("-86400000.999999"));
283+
assertThat(instant.getEpochSecond(), is(-86401L));
284+
assertThat(instant.getNano(), is(999000001));
285+
assertThat(formatter.format(instant), is("-86400000.999999"));
286+
assertThat(Instant.from(formatter.parse(formatter.format(instant))), is(instant));
287+
}
288+
274289
}
275290

276291
/**

0 commit comments

Comments
 (0)