Skip to content
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

Speed up converting of temporal accessor to zoned date time #38172

Merged
merged 1 commit into from
Feb 1, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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