Skip to content

Commit

Permalink
Speed up converting of temporal accessor to zoned date time (#38172)
Browse files Browse the repository at this point in the history
The existing implementation was slow due to exceptions being thrown if
an accessor did not have a time zone. This implementation queries for
having a timezone, local time and local date and also checks for an
instant preventing to throw an exception and thus speeding up the conversion.

This removes the existing method and create a new one named
DateFormatters.from(TemporalAccessor accessor) to resemble the naming of
the java time ones.

Before this change an epoch millis parser using the toZonedDateTime
method took approximately 50x longer.

Relates #37826

This commit also backports #38171
  • Loading branch information
spinscale authored Feb 1, 2019
1 parent f8191e6 commit 1094d3b
Show file tree
Hide file tree
Showing 10 changed files with 210 additions and 100 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* Licensed to Elasticsearch under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.elasticsearch.benchmark.time;

import org.elasticsearch.common.time.DateFormatter;
import org.elasticsearch.common.time.DateFormatters;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;

import java.time.temporal.TemporalAccessor;
import java.util.concurrent.TimeUnit;

@Fork(3)
@Warmup(iterations = 10)
@Measurement(iterations = 10)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
@SuppressWarnings("unused") //invoked by benchmarking framework
public class DateFormatterFromBenchmark {

private final TemporalAccessor accessor = DateFormatter.forPattern("epoch_millis").parse("1234567890");

@Benchmark
public TemporalAccessor benchmarkFrom() {
// benchmark an accessor that does not contain a timezone
// this used to throw an exception earlier and thus was very very slow
return DateFormatters.from(accessor);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public static Date parseTimeField(XContentParser parser, String fieldName) throw
if (parser.currentToken() == XContentParser.Token.VALUE_NUMBER) {
return new Date(parser.longValue());
} else if (parser.currentToken() == XContentParser.Token.VALUE_STRING) {
return new Date(DateFormatters.toZonedDateTime(DateTimeFormatter.ISO_INSTANT.parse(parser.text())).toInstant().toEpochMilli());
return new Date(DateFormatters.from(DateTimeFormatter.ISO_INSTANT.parse(parser.text())).toInstant().toEpochMilli());
}
throw new IllegalArgumentException(
"unexpected token [" + parser.currentToken() + "] for [" + fieldName + "]");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,20 @@
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoField;
import java.time.temporal.TemporalAccessor;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.function.Function;

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_DAY;
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_DAY;

enum DateFormat {
Iso8601 {
@Override
Expand Down Expand Up @@ -71,6 +81,9 @@ private long parseMillis(String date) {
}
},
Java {
private final List<ChronoField> FIELDS =
Arrays.asList(NANO_OF_SECOND, SECOND_OF_DAY, MINUTE_OF_DAY, HOUR_OF_DAY, DAY_OF_MONTH, MONTH_OF_YEAR);

@Override
Function<String, DateTime> getFunction(String format, DateTimeZone timezone, Locale locale) {
// in case you are wondering why we do not call 'DateFormatter.forPattern(format)' for all cases here, but only for the
Expand All @@ -85,9 +98,21 @@ Function<String, DateTime> getFunction(String format, DateTimeZone timezone, Loc
.withLocale(locale)
.withZone(DateUtils.dateTimeZoneToZoneId(timezone));
return text -> {
ZonedDateTime defaultZonedDateTime = Instant.EPOCH.atZone(ZoneOffset.UTC).withYear(year);
TemporalAccessor accessor = formatter.parse(text);
long millis = DateFormatters.toZonedDateTime(accessor, defaultZonedDateTime).toInstant().toEpochMilli();
// if there is no year, we fall back to the current one and
// fill the rest of the date up with the parsed date
if (accessor.isSupported(ChronoField.YEAR) == false) {
ZonedDateTime newTime = Instant.EPOCH.atZone(ZoneOffset.UTC).withYear(year);
for (ChronoField field : FIELDS) {
if (accessor.isSupported(field)) {
newTime = newTime.with(field, accessor.get(field));
}
}

accessor = newTime.withZoneSameLocal(DateUtils.dateTimeZoneToZoneId(timezone));
}

long millis = DateFormatters.from(accessor).toInstant().toEpochMilli();
return new DateTime(millis, timezone);
};
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,20 @@

package org.elasticsearch.ingest.common;

import org.elasticsearch.common.time.DateUtils;
import org.elasticsearch.test.ESTestCase;
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;

import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.function.Function;

import static org.hamcrest.Matchers.is;
import static org.hamcrest.core.IsEqual.equalTo;

public class DateFormatTests extends ESTestCase {
Expand All @@ -42,6 +46,31 @@ public void testParseJoda() {
equalTo("11 24 01:29:01"));
}

public void testParseJodaDefaultYear() {
String format = randomFrom("8dd/MM", "dd/MM");
DateTimeZone timezone = DateUtils.zoneIdToDateTimeZone(ZoneId.of("Europe/Amsterdam"));
Function<String, DateTime> javaFunction = DateFormat.Java.getFunction(format, timezone, Locale.ENGLISH);
int year = ZonedDateTime.now(ZoneOffset.UTC).getYear();
DateTime dateTime = javaFunction.apply("12/06");
assertThat(dateTime.getYear(), is(year));
assertThat(dateTime.toString(), is(year + "-06-12T00:00:00.000+02:00"));
}

// if there is a time around end of year, which is different in UTC make sure the result is the same
public void testParseDefaultYearBackwardsCompatible() {
ZoneId zoneId = ZoneId.of("America/New_York");
DateTimeZone timezone = DateUtils.zoneIdToDateTimeZone(zoneId);
int year = ZonedDateTime.now(zoneId).getYear();
int nextYear = year + 1;

DateTime javaDateTime = DateFormat.Java.getFunction("8dd/MM HH:mm", timezone, Locale.ENGLISH).apply("31/12 23:59");
DateTime jodaDateTime = DateFormat.Java.getFunction("dd/MM HH:mm", timezone, Locale.ENGLISH).apply("31/12 23:59");
assertThat(javaDateTime.getYear(), is(jodaDateTime.getYear()));
assertThat(year, is(jodaDateTime.getYear()));
assertThat(javaDateTime.withZone(DateTimeZone.UTC), is(jodaDateTime.withZone(DateTimeZone.UTC)));
assertThat(nextYear, is(jodaDateTime.withZone(DateTimeZone.UTC).getYear()));
}

public void testParseUnixMs() {
assertThat(DateFormat.UnixMs.getFunction(null, DateTimeZone.UTC, null).apply("1000500").getMillis(), equalTo(1000500L));
}
Expand Down
169 changes: 86 additions & 83 deletions server/src/main/java/org/elasticsearch/common/time/DateFormatters.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@
package org.elasticsearch.common.time;

import org.elasticsearch.common.Strings;
import org.elasticsearch.common.SuppressForbidden;

import java.time.DateTimeException;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.Year;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
Expand Down Expand Up @@ -1503,105 +1505,106 @@ static JavaDateFormatter merge(String pattern, List<DateFormatter> formatters) {
dateTimeFormatters.toArray(new DateTimeFormatter[0]));
}

private static final ZonedDateTime EPOCH_ZONED_DATE_TIME = Instant.EPOCH.atZone(ZoneOffset.UTC);
private static final LocalDate LOCALDATE_EPOCH = LocalDate.of(1970, 1, 1);

public static ZonedDateTime toZonedDateTime(TemporalAccessor accessor) {
return toZonedDateTime(accessor, EPOCH_ZONED_DATE_TIME);
}

public static ZonedDateTime toZonedDateTime(TemporalAccessor accessor, ZonedDateTime defaults) {
try {
return ZonedDateTime.from(accessor);
} catch (DateTimeException e ) {
/**
* Convert a temporal accessor to a zoned date time object - as performant as possible.
* The .from() methods from the JDK are throwing exceptions when for example ZonedDateTime.from(accessor)
* or Instant.from(accessor). This results in a huge performance penalty and should be prevented
* This method prevents exceptions by querying the accessor for certain capabilities
* and then act on it accordingly
*
* This action assumes that we can reliably fall back to some defaults if not all parts of a
* zoned date time are set
*
* - If a zoned date time is passed, it is returned
* - If no timezone is found, ZoneOffset.UTC is used
* - If we find a time and a date, converting to a ZonedDateTime is straight forward,
* no defaults will be applied
* - If an accessor only containing of seconds and nanos is found (like epoch_millis/second)
* an Instant is created out of that, that becomes a ZonedDateTime with a time zone
* - If no time is given, the start of the day is used
* - If no month of the year is found, the first day of the year is used
* - If an iso based weekyear is found, but not week is specified, the first monday
* of the new year is chosen (reataining BWC to joda time)
* - If an iso based weekyear is found and an iso based weekyear week, the start
* of the day is used
*
* @param accessor The accessor returned from a parser
*
* @return The converted zoned date time
*/
public static ZonedDateTime from(TemporalAccessor accessor) {
if (accessor instanceof ZonedDateTime) {
return (ZonedDateTime) accessor;
}

ZonedDateTime result = defaults;

// special case epoch seconds
if (accessor.isSupported(ChronoField.INSTANT_SECONDS)) {
result = result.with(ChronoField.INSTANT_SECONDS, accessor.getLong(ChronoField.INSTANT_SECONDS));
if (accessor.isSupported(ChronoField.NANO_OF_SECOND)) {
result = result.with(ChronoField.NANO_OF_SECOND, accessor.getLong(ChronoField.NANO_OF_SECOND));
}
return result;
ZoneId zoneId = accessor.query(TemporalQueries.zone());
if (zoneId == null) {
zoneId = ZoneOffset.UTC;
}

// try to set current year
if (accessor.isSupported(ChronoField.YEAR)) {
result = result.with(ChronoField.YEAR, accessor.getLong(ChronoField.YEAR));
} else if (accessor.isSupported(ChronoField.YEAR_OF_ERA)) {
result = result.with(ChronoField.YEAR_OF_ERA, accessor.getLong(ChronoField.YEAR_OF_ERA));
LocalDate localDate = accessor.query(TemporalQueries.localDate());
LocalTime localTime = accessor.query(TemporalQueries.localTime());
boolean isLocalDateSet = localDate != null;
boolean isLocalTimeSet = localTime != null;

// the first two cases are the most common, so this allows us to exit early when parsing dates
if (isLocalDateSet && isLocalTimeSet) {
return of(localDate, localTime, zoneId);
} else if (accessor.isSupported(ChronoField.INSTANT_SECONDS) && accessor.isSupported(NANO_OF_SECOND)) {
return Instant.from(accessor).atZone(zoneId);
} else if (isLocalDateSet) {
return localDate.atStartOfDay(zoneId);
} else if (isLocalTimeSet) {
return of(getLocaldate(accessor), localTime, zoneId);
} else if (accessor.isSupported(ChronoField.YEAR)) {
if (accessor.isSupported(MONTH_OF_YEAR)) {
return getFirstOfMonth(accessor).atStartOfDay(zoneId);
} else {
return Year.of(accessor.get(ChronoField.YEAR)).atDay(1).atStartOfDay(zoneId);
}
} else if (accessor.isSupported(MONTH_OF_YEAR)) {
// missing year, falling back to the epoch and then filling
return getLocaldate(accessor).atStartOfDay(zoneId);
} else if (accessor.isSupported(WeekFields.ISO.weekBasedYear())) {
if (accessor.isSupported(WeekFields.ISO.weekOfWeekBasedYear())) {
return LocalDate.from(result)
.with(WeekFields.ISO.weekBasedYear(), accessor.getLong(WeekFields.ISO.weekBasedYear()))
.withDayOfMonth(1) // makes this compatible with joda
return Year.of(accessor.get(WeekFields.ISO.weekBasedYear()))
.atDay(1)
.with(WeekFields.ISO.weekOfWeekBasedYear(), accessor.getLong(WeekFields.ISO.weekOfWeekBasedYear()))
.atStartOfDay(ZoneOffset.UTC);
.atStartOfDay(zoneId);
} else {
return LocalDate.from(result)
.with(WeekFields.ISO.weekBasedYear(), accessor.getLong(WeekFields.ISO.weekBasedYear()))
// this exists solely to be BWC compatible with joda
// .with(TemporalAdjusters.nextOrSame(DayOfWeek.MONDAY))
return Year.of(accessor.get(WeekFields.ISO.weekBasedYear()))
.atDay(1)
.with(TemporalAdjusters.firstInMonth(DayOfWeek.MONDAY))
.atStartOfDay(defaults.getZone());
// return result.withHour(0).withMinute(0).withSecond(0)
// .with(WeekFields.ISO.weekBasedYear(), 0)
// .with(WeekFields.ISO.weekBasedYear(), accessor.getLong(WeekFields.ISO.weekBasedYear()));
// return ((ZonedDateTime) tmp).with(WeekFields.ISO.weekOfWeekBasedYear(), 1);
.atStartOfDay(zoneId);
}
} else if (accessor.isSupported(IsoFields.WEEK_BASED_YEAR)) {
// special case weekbased year
result = result.with(IsoFields.WEEK_BASED_YEAR, accessor.getLong(IsoFields.WEEK_BASED_YEAR));
if (accessor.isSupported(IsoFields.WEEK_OF_WEEK_BASED_YEAR)) {
result = result.with(IsoFields.WEEK_OF_WEEK_BASED_YEAR, accessor.getLong(IsoFields.WEEK_OF_WEEK_BASED_YEAR));
}
return result;
}

// month
if (accessor.isSupported(ChronoField.MONTH_OF_YEAR)) {
result = result.with(ChronoField.MONTH_OF_YEAR, accessor.getLong(ChronoField.MONTH_OF_YEAR));
}

// day of month
if (accessor.isSupported(ChronoField.DAY_OF_MONTH)) {
result = result.with(ChronoField.DAY_OF_MONTH, accessor.getLong(ChronoField.DAY_OF_MONTH));
}

// hour
if (accessor.isSupported(ChronoField.HOUR_OF_DAY)) {
result = result.with(ChronoField.HOUR_OF_DAY, accessor.getLong(ChronoField.HOUR_OF_DAY));
}

// minute
if (accessor.isSupported(ChronoField.MINUTE_OF_HOUR)) {
result = result.with(ChronoField.MINUTE_OF_HOUR, accessor.getLong(ChronoField.MINUTE_OF_HOUR));
}

// second
if (accessor.isSupported(ChronoField.SECOND_OF_MINUTE)) {
result = result.with(ChronoField.SECOND_OF_MINUTE, accessor.getLong(ChronoField.SECOND_OF_MINUTE));
}

if (accessor.isSupported(ChronoField.OFFSET_SECONDS)) {
result = result.withZoneSameLocal(ZoneOffset.ofTotalSeconds(accessor.get(ChronoField.OFFSET_SECONDS)));
}
// we should not reach this piece of code, everything being parsed we should be able to
// convert to a zoned date time! If not, we have to extend the above methods
throw new IllegalArgumentException("temporal accessor [" + accessor + "] cannot be converted to zoned date time");
}

// millis
if (accessor.isSupported(ChronoField.MILLI_OF_SECOND)) {
result = result.with(ChronoField.MILLI_OF_SECOND, accessor.getLong(ChronoField.MILLI_OF_SECOND));
private static LocalDate getLocaldate(TemporalAccessor accessor) {
if (accessor.isSupported(MONTH_OF_YEAR)) {
if (accessor.isSupported(DAY_OF_MONTH)) {
return LocalDate.of(1970, accessor.get(MONTH_OF_YEAR), accessor.get(DAY_OF_MONTH));
} else {
return LocalDate.of(1970, accessor.get(MONTH_OF_YEAR), 1);
}
}

if (accessor.isSupported(ChronoField.NANO_OF_SECOND)) {
result = result.with(ChronoField.NANO_OF_SECOND, accessor.getLong(ChronoField.NANO_OF_SECOND));
}
return LOCALDATE_EPOCH;
}

ZoneId zoneOffset = accessor.query(TemporalQueries.zone());
if (zoneOffset != null) {
result = result.withZoneSameLocal(zoneOffset);
}
@SuppressForbidden(reason = "ZonedDateTime.of is fine here")
private static ZonedDateTime of(LocalDate localDate, LocalTime localTime, ZoneId zoneId) {
return ZonedDateTime.of(localDate, localTime, zoneId);
}

return result;
@SuppressForbidden(reason = "LocalDate.of is fine here")
private static LocalDate getFirstOfMonth(TemporalAccessor accessor) {
return LocalDate.of(accessor.get(ChronoField.YEAR), accessor.get(MONTH_OF_YEAR), 1);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -217,15 +217,15 @@ private long parseDateTime(String value, ZoneId timeZone, boolean roundUpIfNoTim
DateTimeFormatter formatter = roundUpIfNoTime ? this.roundUpFormatter : this.formatter;
try {
if (timeZone == null) {
return DateFormatters.toZonedDateTime(formatter.parse(value)).toInstant().toEpochMilli();
return DateFormatters.from(formatter.parse(value)).toInstant().toEpochMilli();
} else {
TemporalAccessor accessor = formatter.parse(value);
ZoneId zoneId = TemporalQueries.zone().queryFrom(accessor);
if (zoneId != null) {
timeZone = zoneId;
}

return DateFormatters.toZonedDateTime(accessor).withZoneSameLocal(timeZone).toInstant().toEpochMilli();
return DateFormatters.from(accessor).withZoneSameLocal(timeZone).toInstant().toEpochMilli();
}
} catch (IllegalArgumentException | DateTimeException e) {
throw new ElasticsearchParseException("failed to parse date field [{}] in format [{}]: [{}]", e, value, format, e.getMessage());
Expand Down
Loading

0 comments on commit 1094d3b

Please sign in to comment.