Skip to content

Commit 19e9eb3

Browse files
committed
Fix rounding of time values near to overlapping days (#28151)
Sometimes, in some places, the clocks are set back across midnight, leading to overlapping days. This was not handled as expected, and this change fixes this. Additionally, in this situation it is not true that rounding a time down to the nearest day is a monotonic operation, as asserted in these tests. This change suppresses those assertions in those rare cases. Fixes #27966.
1 parent 10cfb51 commit 19e9eb3

File tree

2 files changed

+232
-45
lines changed

2 files changed

+232
-45
lines changed

server/src/main/java/org/elasticsearch/common/rounding/Rounding.java

Lines changed: 95 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,15 @@ static class TimeUnitRounding extends Rounding {
110110
private DateTimeUnit unit;
111111
private DateTimeField field;
112112
private DateTimeZone timeZone;
113+
private boolean unitRoundsToMidnight;
113114

114115
TimeUnitRounding() { // for serialization
115116
}
116117

117118
TimeUnitRounding(DateTimeUnit unit, DateTimeZone timeZone) {
118119
this.unit = unit;
119120
this.field = unit.field(timeZone);
121+
unitRoundsToMidnight = this.field.getDurationField().getUnitMillis() > 60L * 60L * 1000L;
120122
this.timeZone = timeZone;
121123
}
122124

@@ -125,44 +127,102 @@ public byte id() {
125127
return ID;
126128
}
127129

130+
/**
131+
* @return The latest timestamp T which is strictly before utcMillis
132+
* and such that timeZone.getOffset(T) != timeZone.getOffset(utcMillis).
133+
* If there is no such T, returns Long.MAX_VALUE.
134+
*/
135+
private long previousTransition(long utcMillis) {
136+
final int offsetAtInputTime = timeZone.getOffset(utcMillis);
137+
do {
138+
// Some timezones have transitions that do not change the offset, so we have to
139+
// repeatedly call previousTransition until a nontrivial transition is found.
140+
141+
long previousTransition = timeZone.previousTransition(utcMillis);
142+
if (previousTransition == utcMillis) {
143+
// There are no earlier transitions
144+
return Long.MAX_VALUE;
145+
}
146+
assert previousTransition < utcMillis; // Progress was made
147+
utcMillis = previousTransition;
148+
} while (timeZone.getOffset(utcMillis) == offsetAtInputTime);
149+
150+
return utcMillis;
151+
}
152+
128153
@Override
129154
public long round(long utcMillis) {
130-
long rounded = field.roundFloor(utcMillis);
131-
if (timeZone.isFixed() == false) {
132-
// special cases for non-fixed time zones with dst transitions
133-
if (timeZone.getOffset(utcMillis) != timeZone.getOffset(rounded)) {
134-
/*
135-
* the offset change indicates a dst transition. In some
136-
* edge cases this will result in a value that is not a
137-
* rounded value before the transition. We round again to
138-
* make sure we really return a rounded value. This will
139-
* have no effect in cases where we already had a valid
140-
* rounded value
141-
*/
142-
rounded = field.roundFloor(rounded);
143-
} else {
144-
/*
145-
* check if the current time instant is at a start of a DST
146-
* overlap by comparing the offset of the instant and the
147-
* previous millisecond. We want to detect negative offset
148-
* changes that result in an overlap
149-
*/
150-
if (timeZone.getOffset(rounded) < timeZone.getOffset(rounded - 1)) {
151-
/*
152-
* we are rounding a date just after a DST overlap. if
153-
* the overlap is smaller than the time unit we are
154-
* rounding to, we want to add the overlapping part to
155-
* the following rounding interval
156-
*/
157-
long previousRounded = field.roundFloor(rounded - 1);
158-
if (rounded - previousRounded < field.getDurationField().getUnitMillis()) {
159-
rounded = previousRounded;
160-
}
161-
}
155+
156+
// field.roundFloor() works as long as the offset doesn't change. It is worth getting this case out of the way first, as
157+
// the calculations for fixing things near to offset changes are a little expensive and are unnecessary in the common case
158+
// of working in UTC.
159+
if (timeZone.isFixed()) {
160+
return field.roundFloor(utcMillis);
161+
}
162+
163+
// When rounding to hours we consider any local time of the form 'xx:00:00' as rounded, even though this gives duplicate
164+
// bucket names for the times when the clocks go back. Shorter units behave similarly. However, longer units round down to
165+
// midnight, and on the days where there are two midnights we would rather pick the earlier one, so that buckets are
166+
// uniquely identified by the date.
167+
if (unitRoundsToMidnight) {
168+
final long anyLocalStartOfDay = field.roundFloor(utcMillis);
169+
// `anyLocalStartOfDay` is _supposed_ to be the Unix timestamp for the start of the day in question in the current time
170+
// zone. Mostly this just means "midnight", which is fine, and on days with no local midnight it's the first time that
171+
// does occur on that day which is also ok. However, on days with >1 local midnight this is _one_ of the midnights, but
172+
// may not be the first. Check whether this is happening, and fix it if so.
173+
174+
final long previousTransition = previousTransition(anyLocalStartOfDay);
175+
176+
if (previousTransition == Long.MAX_VALUE) {
177+
// No previous transitions, so there can't be another earlier local midnight.
178+
return anyLocalStartOfDay;
179+
}
180+
181+
final long currentOffset = timeZone.getOffset(anyLocalStartOfDay);
182+
final long previousOffset = timeZone.getOffset(previousTransition);
183+
assert currentOffset != previousOffset;
184+
185+
// NB we only assume interference from one previous transition. It's theoretically possible to have two transitions in
186+
// quick succession, both of which have a midnight in them, but this doesn't appear to happen in the TZDB so (a) it's
187+
// pointless to implement and (b) it won't be tested. I recognise that this comment is tempting fate and will likely
188+
// cause this very situation to occur in the near future, and eagerly look forward to fixing this using a loop over
189+
// previous transitions when it happens.
190+
191+
final long alsoLocalStartOfDay = anyLocalStartOfDay + currentOffset - previousOffset;
192+
// `alsoLocalStartOfDay` is the Unix timestamp for the start of the day in question if the previous offset were in
193+
// effect.
194+
195+
if (alsoLocalStartOfDay <= previousTransition) {
196+
// Therefore the previous offset _is_ in effect at `alsoLocalStartOfDay`, and it's earlier than anyLocalStartOfDay,
197+
// so this is the answer to use.
198+
return alsoLocalStartOfDay;
199+
}
200+
else {
201+
// The previous offset is not in effect at `alsoLocalStartOfDay`, so the current offset must be.
202+
return anyLocalStartOfDay;
162203
}
204+
205+
} else {
206+
do {
207+
long rounded = field.roundFloor(utcMillis);
208+
209+
// field.roundFloor() mostly works as long as the offset hasn't changed in [rounded, utcMillis], so look at where
210+
// the offset most recently changed.
211+
212+
final long previousTransition = previousTransition(utcMillis);
213+
214+
if (previousTransition == Long.MAX_VALUE || previousTransition < rounded) {
215+
// The offset did not change in [rounded, utcMillis], so roundFloor() worked as expected.
216+
return rounded;
217+
}
218+
219+
// The offset _did_ change in [rounded, utcMillis]. Put differently, this means that none of the times in
220+
// [previousTransition+1, utcMillis] were rounded, so the rounded time must be <= previousTransition. This means
221+
// it's sufficient to try and round previousTransition down.
222+
assert previousTransition < utcMillis;
223+
utcMillis = previousTransition;
224+
} while (true);
163225
}
164-
assert rounded == field.roundFloor(rounded);
165-
return rounded;
166226
}
167227

168228
@Override
@@ -182,6 +242,7 @@ public void readFrom(StreamInput in) throws IOException {
182242
unit = DateTimeUnit.resolve(in.readByte());
183243
timeZone = DateTimeZone.forID(in.readString());
184244
field = unit.field(timeZone);
245+
unitRoundsToMidnight = field.getDurationField().getUnitMillis() > 60L * 60L * 1000L;
185246
}
186247

187248
@Override

server/src/test/java/org/elasticsearch/common/rounding/TimeZoneRoundingTests.java

Lines changed: 137 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,101 @@ public void testEdgeCasesTransition() {
514514
}
515515
}
516516

517+
public void testDST_Europe_Rome() {
518+
// time zone "Europe/Rome", rounding to days. Rome had two midnights on the day the clocks went back in 1978, and
519+
// timeZone.convertLocalToUTC() gives the later of the two because Rome is east of UTC, whereas we want the earlier.
520+
521+
DateTimeUnit timeUnit = DateTimeUnit.DAY_OF_MONTH;
522+
DateTimeZone tz = DateTimeZone.forID("Europe/Rome");
523+
Rounding rounding = new TimeUnitRounding(timeUnit, tz);
524+
525+
{
526+
long timeBeforeFirstMidnight = time("1978-09-30T23:59:00+02:00");
527+
long floor = rounding.round(timeBeforeFirstMidnight);
528+
assertThat(floor, isDate(time("1978-09-30T00:00:00+02:00"), tz));
529+
}
530+
531+
{
532+
long timeBetweenMidnights = time("1978-10-01T00:30:00+02:00");
533+
long floor = rounding.round(timeBetweenMidnights);
534+
assertThat(floor, isDate(time("1978-10-01T00:00:00+02:00"), tz));
535+
}
536+
537+
{
538+
long timeAfterSecondMidnight = time("1978-10-01T00:30:00+01:00");
539+
long floor = rounding.round(timeAfterSecondMidnight);
540+
assertThat(floor, isDate(time("1978-10-01T00:00:00+02:00"), tz));
541+
542+
long prevFloor = rounding.round(floor - 1);
543+
assertThat(prevFloor, lessThan(floor));
544+
assertThat(prevFloor, isDate(time("1978-09-30T00:00:00+02:00"), tz));
545+
}
546+
}
547+
548+
/**
549+
* Test for a time zone whose days overlap because the clocks are set back across midnight at the end of DST.
550+
*/
551+
public void testDST_America_St_Johns() {
552+
// time zone "America/St_Johns", rounding to days.
553+
DateTimeUnit timeUnit = DateTimeUnit.DAY_OF_MONTH;
554+
DateTimeZone tz = DateTimeZone.forID("America/St_Johns");
555+
Rounding rounding = new TimeUnitRounding(timeUnit, tz);
556+
557+
// 29 October 2006 - Daylight Saving Time ended, changing the UTC offset from -02:30 to -03:30.
558+
// This happened at 02:31 UTC, 00:01 local time, so the clocks were set back 1 hour to 23:01 on the 28th.
559+
// This means that 2006-10-29 has _two_ midnights, one in the -02:30 offset and one in the -03:30 offset.
560+
// Only the first of these is considered "rounded". Moreover, the extra time between 23:01 and 23:59
561+
// should be considered as part of the 28th even though it comes after midnight on the 29th.
562+
563+
{
564+
// Times before the first midnight should be rounded up to the first midnight.
565+
long timeBeforeFirstMidnight = time("2006-10-28T23:30:00.000-02:30");
566+
long floor = rounding.round(timeBeforeFirstMidnight);
567+
assertThat(floor, isDate(time("2006-10-28T00:00:00.000-02:30"), tz));
568+
long ceiling = rounding.nextRoundingValue(timeBeforeFirstMidnight);
569+
assertThat(ceiling, isDate(time("2006-10-29T00:00:00.000-02:30"), tz));
570+
assertInterval(floor, timeBeforeFirstMidnight, ceiling, rounding, tz);
571+
}
572+
573+
{
574+
// Times between the two midnights which are on the later day should be rounded down to the later day's midnight.
575+
long timeBetweenMidnights = time("2006-10-29T00:00:30.000-02:30");
576+
// (this is halfway through the last minute before the clocks changed, in which local time was ambiguous)
577+
578+
long floor = rounding.round(timeBetweenMidnights);
579+
assertThat(floor, isDate(time("2006-10-29T00:00:00.000-02:30"), tz));
580+
581+
long ceiling = rounding.nextRoundingValue(timeBetweenMidnights);
582+
assertThat(ceiling, isDate(time("2006-10-30T00:00:00.000-03:30"), tz));
583+
584+
assertInterval(floor, timeBetweenMidnights, ceiling, rounding, tz);
585+
}
586+
587+
{
588+
// Times between the two midnights which are on the earlier day should be rounded down to the earlier day's midnight.
589+
long timeBetweenMidnights = time("2006-10-28T23:30:00.000-03:30");
590+
// (this is halfway through the hour after the clocks changed, in which local time was ambiguous)
591+
592+
long floor = rounding.round(timeBetweenMidnights);
593+
assertThat(floor, isDate(time("2006-10-28T00:00:00.000-02:30"), tz));
594+
595+
long ceiling = rounding.nextRoundingValue(timeBetweenMidnights);
596+
assertThat(ceiling, isDate(time("2006-10-29T00:00:00.000-02:30"), tz));
597+
598+
assertInterval(floor, timeBetweenMidnights, ceiling, rounding, tz);
599+
}
600+
601+
{
602+
// Times after the second midnight should be rounded down to the first midnight.
603+
long timeAfterSecondMidnight = time("2006-10-29T06:00:00.000-03:30");
604+
long floor = rounding.round(timeAfterSecondMidnight);
605+
assertThat(floor, isDate(time("2006-10-29T00:00:00.000-02:30"), tz));
606+
long ceiling = rounding.nextRoundingValue(timeAfterSecondMidnight);
607+
assertThat(ceiling, isDate(time("2006-10-30T00:00:00.000-03:30"), tz));
608+
assertInterval(floor, timeAfterSecondMidnight, ceiling, rounding, tz);
609+
}
610+
}
611+
517612
/**
518613
* tests for dst transition with overlaps and day roundings.
519614
*/
@@ -527,12 +622,17 @@ public void testDST_END_Edgecases() {
527622

528623
// Sunday, 29 October 2000, 01:00:00 clocks were turned backward 1 hour
529624
// to Sunday, 29 October 2000, 00:00:00 local standard time instead
625+
// which means there were two midnights that day.
530626

531627
long midnightBeforeTransition = time("2000-10-29T00:00:00", tz);
628+
long midnightOfTransition = time("2000-10-29T00:00:00-01:00");
629+
assertEquals(60L * 60L * 1000L, midnightOfTransition - midnightBeforeTransition);
532630
long nextMidnight = time("2000-10-30T00:00:00", tz);
533631

534632
assertInterval(midnightBeforeTransition, nextMidnight, rounding, 25 * 60, tz);
535633

634+
assertThat(rounding.round(time("2000-10-29T06:00:00-01:00")), isDate(time("2000-10-29T00:00:00Z"), tz));
635+
536636
// Second case, dst happens at 0am local time, switching back one hour to 23pm local time.
537637
// We want the overlapping hour to count for the previous day here
538638

@@ -584,26 +684,52 @@ private static void assertInterval(long rounded, long nextRoundingValue, Roundin
584684
* @param nextRoundingValue the expected upper end of the rounding interval
585685
* @param rounding the rounding instance
586686
*/
587-
private static void assertInterval(long rounded, long unrounded, long nextRoundingValue, Rounding rounding,
588-
DateTimeZone tz) {
589-
assert rounded <= unrounded && unrounded <= nextRoundingValue;
687+
private static void assertInterval(long rounded, long unrounded, long nextRoundingValue, Rounding rounding, DateTimeZone tz) {
590688
assertThat("rounding should be idempotent ", rounding.round(rounded), isDate(rounded, tz));
591689
assertThat("rounded value smaller or equal than unrounded" + rounding, rounded, lessThanOrEqualTo(unrounded));
592690
assertThat("values less than rounded should round further down" + rounding, rounding.round(rounded - 1), lessThan(rounded));
593-
assertThat("nextRounding value should be greater than date" + rounding, nextRoundingValue, greaterThan(unrounded));
594691
assertThat("nextRounding value should be a rounded date", rounding.round(nextRoundingValue), isDate(nextRoundingValue, tz));
595692
assertThat("values above nextRounding should round down there", rounding.round(nextRoundingValue + 1),
596693
isDate(nextRoundingValue, tz));
597694

598-
long dateBetween = dateBetween(rounded, nextRoundingValue);
599-
assertThat("dateBetween should round down to roundedDate", rounding.round(dateBetween), isDate(rounded, tz));
600-
assertThat("dateBetween should round up to nextRoundingValue", rounding.nextRoundingValue(dateBetween),
601-
isDate(nextRoundingValue, tz));
695+
if (isTimeWithWellDefinedRounding(tz, unrounded)) {
696+
assertThat("nextRounding value should be greater than date" + rounding, nextRoundingValue, greaterThan(unrounded));
697+
698+
long dateBetween = dateBetween(rounded, nextRoundingValue);
699+
assertThat("dateBetween [" + new DateTime(dateBetween, tz) + "] should round down to roundedDate",
700+
rounding.round(dateBetween), isDate(rounded, tz));
701+
assertThat("dateBetween [" + new DateTime(dateBetween, tz) + "] should round up to nextRoundingValue",
702+
rounding.nextRoundingValue(dateBetween), isDate(nextRoundingValue, tz));
703+
}
704+
}
705+
706+
private static boolean isTimeWithWellDefinedRounding(DateTimeZone tz, long t) {
707+
if (tz.getID().equals("America/St_Johns")
708+
|| tz.getID().equals("America/Goose_Bay")
709+
|| tz.getID().equals("America/Moncton")
710+
|| tz.getID().equals("Canada/Newfoundland")) {
711+
712+
// Clocks went back at 00:01 between 1987 and 2010, causing overlapping days.
713+
// These timezones are otherwise uninteresting, so just skip this period.
714+
715+
return t <= time("1987-10-01T00:00:00Z")
716+
|| t >= time("2010-12-01T00:00:00Z");
717+
}
718+
719+
if (tz.getID().equals("Antarctica/Casey")) {
720+
721+
// Clocks went back 3 hours at 02:00 on 2010-03-05, causing overlapping days.
722+
723+
return t <= time("2010-03-03T00:00:00Z")
724+
|| t >= time("2010-03-07T00:00:00Z");
725+
}
726+
727+
return true;
602728
}
603729

604730
private static long dateBetween(long lower, long upper) {
605-
long dateBetween = lower + Math.abs((randomLong() % (upper - lower)));
606-
assert lower <= dateBetween && dateBetween <= upper;
731+
long dateBetween = randomLongBetween(lower, upper - 1);
732+
assert lower <= dateBetween && dateBetween < upper;
607733
return dateBetween;
608734
}
609735

@@ -629,7 +755,7 @@ public boolean matchesSafely(final Long item) {
629755

630756
@Override
631757
public void describeTo(Description description) {
632-
description.appendText("Expected: " + new DateTime(expected, tz) + " [" + expected + "] ");
758+
description.appendText(new DateTime(expected, tz) + " [" + expected + "] ");
633759
}
634760

635761
@Override

0 commit comments

Comments
 (0)