Skip to content

Commit 2e585a8

Browse files
authored
Fix bug when formatting epoch dates (#73955) (#74139)
Date based aggregations accept a timezone, which gets applied to both the bucketing logic and the formatter. This is usually what you want, but in the case of date formats where a timezone doesn't make any sense, it can create problems. In particular, our formatting logic and our parsing logic were doing different things for epoch_second and epoch_millis formats with time zones. This led to a problem on composite where we'd return an after key for the last bucket that would parse to a time before the last bucket, so instead of correctly returning an empty response to indicate the end of the aggregation, we'd keep returning the same last page of data.
1 parent 1de7a4a commit 2e585a8

File tree

3 files changed

+172
-2
lines changed

3 files changed

+172
-2
lines changed

rest-api-spec/src/yamlRestTest/resources/rest-api-spec/test/search.aggregation/230_composite.yml

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -941,6 +941,76 @@ setup:
941941
- match: { aggregations.test.buckets.0.key.date: "2017-10-21T00:00:00.000-02:00" }
942942
- match: { aggregations.test.buckets.0.doc_count: 2 }
943943

944+
---
945+
"date_histogram with time_zone epoch format":
946+
# Same as above, but with a different format. We had a bug about this specifically:
947+
# https://github.com/elastic/elasticsearch/issues/68963
948+
- skip:
949+
version: " - 7.6.0"
950+
reason: Fixed in 7.6.0
951+
- do:
952+
index:
953+
index: test
954+
id: 7
955+
body: { "date": "2017-10-22T01:00:00" }
956+
refresh: true
957+
- do:
958+
search:
959+
index: test
960+
body:
961+
aggregations:
962+
test:
963+
composite:
964+
sources: [
965+
{
966+
"date": {
967+
"date_histogram": {
968+
"field": "date",
969+
"calendar_interval": "1d",
970+
"time_zone": "-02:00",
971+
"format": "epoch_second"
972+
}
973+
}
974+
}
975+
]
976+
977+
- match: { hits.total.value: 7 }
978+
- match: { hits.total.relation: eq }
979+
- length: { aggregations.test.buckets: 2 }
980+
- match: { aggregations.test.buckets.0.key.date: "1508464800" }
981+
- match: { aggregations.test.buckets.0.doc_count: 1 }
982+
- match: { aggregations.test.buckets.1.key.date: "1508551200" }
983+
- match: { aggregations.test.buckets.1.doc_count: 2 }
984+
985+
- do:
986+
search:
987+
index: test
988+
body:
989+
aggregations:
990+
test:
991+
composite:
992+
after: {
993+
date: "1508464800"
994+
}
995+
sources: [
996+
{
997+
"date": {
998+
"date_histogram": {
999+
"field": "date",
1000+
"calendar_interval": "1d",
1001+
"time_zone": "-02:00",
1002+
"format": "epoch_second"
1003+
}
1004+
}
1005+
}
1006+
]
1007+
1008+
- match: { hits.total.value: 7 }
1009+
- match: { hits.total.relation: eq }
1010+
- length: { aggregations.test.buckets: 1 }
1011+
- match: { aggregations.test.buckets.0.key.date: "1508551200" }
1012+
- match: { aggregations.test.buckets.0.doc_count: 2 }
1013+
9441014
---
9451015
"date_histogram on date_nanos":
9461016
- skip:

server/src/main/java/org/elasticsearch/search/DocValueFormat.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,9 +232,9 @@ public DateTime(DateFormatter formatter, ZoneId timeZone, DateFieldMapper.Resolu
232232
}
233233

234234
private DateTime(DateFormatter formatter, ZoneId timeZone, DateFieldMapper.Resolution resolution, boolean formatSortValues) {
235-
this.formatter = formatter;
236235
this.timeZone = Objects.requireNonNull(timeZone);
237-
this.parser = formatter.toDateMathParser();
236+
this.formatter = formatter.withZone(timeZone);
237+
this.parser = this.formatter.toDateMathParser();
238238
this.resolution = resolution;
239239
this.formatSortValues = formatSortValues;
240240
}

server/src/test/java/org/elasticsearch/search/DocValueFormatTests.java

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import org.elasticsearch.index.mapper.DateFieldMapper.Resolution;
2121
import org.elasticsearch.test.ESTestCase;
2222

23+
import java.time.ZoneId;
2324
import java.time.ZoneOffset;
2425
import java.util.ArrayList;
2526
import java.util.List;
@@ -245,4 +246,103 @@ public void testBadIp() {
245246
assertThat(e.getMessage(), containsString("mapping"));
246247
assertThat(e.getMessage(), containsString("IP address"));
247248
}
249+
250+
/**
251+
* <p>Test that if we format a datetime using the `epoch_second` format, we can then parse the result
252+
* back into the same value we started with, for all timezones</p>
253+
*
254+
* <p>"Why would you put a timezone on epoch_seconds? it doesn't make sense" you might be asking.
255+
* I was. The key to remember here is that we use the same time zone parameter for date histogram
256+
* bucket generation and formatting. So, while asking for (e.g.) epoch_seconds in New York time
257+
* is nonsensical, asking for day-buckets in New York time with the keys formatted in epoch_seconds
258+
* is pretty standard.</p>
259+
*
260+
* <p>This test validates that if someone does this in composite, we can then parse the after key
261+
* we generated into the correct value. Parsing also happens on missing values.</p>
262+
*/
263+
public void testParseEpochSecondsTimezone() {
264+
ZoneId zone = randomZone();
265+
DocValueFormat.DateTime formatter = new DocValueFormat.DateTime(
266+
DateFormatter.forPattern("epoch_second"),
267+
zone,
268+
Resolution.MILLISECONDS
269+
);
270+
long millis = randomNonNegativeLong();
271+
// Convert to seconds
272+
millis -= (millis % 1000);
273+
assertEquals(
274+
"failed formatting for tz " + zone,
275+
millis,
276+
formatter.parseLong(formatter.format(millis), false, () -> { throw new UnsupportedOperationException("don't use now"); })
277+
);
278+
}
279+
280+
public void testParseEpochMillisTimezone() {
281+
ZoneId zone = randomZone();
282+
DocValueFormat.DateTime formatter = new DocValueFormat.DateTime(
283+
DateFormatter.forPattern("epoch_millis"),
284+
zone,
285+
Resolution.MILLISECONDS
286+
);
287+
long millis = randomNonNegativeLong();
288+
assertEquals(
289+
"failed formatting for tz " + zone,
290+
millis,
291+
formatter.parseLong(formatter.format(millis), false, () -> { throw new UnsupportedOperationException("don't use now"); })
292+
);
293+
}
294+
295+
296+
public void testDateHMSTimezone() {
297+
DocValueFormat.DateTime tokyo = new DocValueFormat.DateTime(
298+
DateFormatter.forPattern("date_hour_minute_second"),
299+
ZoneOffset.ofHours(9),
300+
Resolution.MILLISECONDS
301+
);
302+
DocValueFormat.DateTime utc = new DocValueFormat.DateTime(
303+
DateFormatter.forPattern("date_hour_minute_second"),
304+
ZoneOffset.UTC,
305+
Resolution.MILLISECONDS
306+
);
307+
long millis = 1622567918000L;
308+
assertEquals("2021-06-01T17:18:38", utc.format(millis));
309+
assertEquals("2021-06-02T02:18:38", tokyo.format(millis));
310+
assertEquals(
311+
"couldn't parse UTC",
312+
millis,
313+
utc.parseLong(utc.format(millis), false, () -> { throw new UnsupportedOperationException("don't use now"); })
314+
);
315+
assertEquals(
316+
"couldn't parse Tokyo",
317+
millis,
318+
tokyo.parseLong(tokyo.format(millis), false, () -> { throw new UnsupportedOperationException("don't use now"); })
319+
);
320+
}
321+
322+
public void testDateTimeWithTimezone() {
323+
324+
DocValueFormat.DateTime tokyo = new DocValueFormat.DateTime(
325+
DateFormatter.forPattern("basic_date_time_no_millis"),
326+
ZoneOffset.ofHours(9),
327+
Resolution.MILLISECONDS
328+
);
329+
DocValueFormat.DateTime utc = new DocValueFormat.DateTime(
330+
DateFormatter.forPattern("basic_date_time_no_millis"),
331+
ZoneOffset.UTC,
332+
Resolution.MILLISECONDS
333+
);
334+
long millis = 1622567918000L;
335+
assertEquals("20210601T171838Z", utc.format(millis));
336+
assertEquals("20210602T021838+09:00", tokyo.format(millis));
337+
assertEquals(
338+
"couldn't parse UTC",
339+
millis,
340+
utc.parseLong(utc.format(millis), false, () -> { throw new UnsupportedOperationException("don't use now"); })
341+
);
342+
assertEquals(
343+
"couldn't parse Tokyo",
344+
millis,
345+
tokyo.parseLong(tokyo.format(millis), false, () -> { throw new UnsupportedOperationException("don't use now"); })
346+
);
347+
}
248348
}

0 commit comments

Comments
 (0)