Skip to content

Commit 63d3b4e

Browse files
committed
Fix various issues with datetime arithmetics
Fixes #488 Fixes #487 Fixes #484
1 parent 0650da9 commit 63d3b4e

File tree

3 files changed

+144
-52
lines changed

3 files changed

+144
-52
lines changed

core/common/src/Instant.kt

Lines changed: 32 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -450,27 +450,10 @@ public fun String.toInstant(): Instant = Instant.parse(this)
450450
public fun Instant.plus(period: DateTimePeriod, timeZone: TimeZone): Instant = try {
451451
with(period) {
452452
val initialOffset = offsetIn(timeZone)
453-
val initialLdt = toLocalDateTimeFailing(initialOffset)
454-
val instantAfterMonths: Instant
455-
val offsetAfterMonths: UtcOffset
456-
val ldtAfterMonths: LocalDateTime
457-
if (totalMonths != 0L) {
458-
val unresolvedLdtWithMonths = initialLdt.plus(totalMonths, DateTimeUnit.MONTH)
459-
instantAfterMonths = localDateTimeToInstant(unresolvedLdtWithMonths, timeZone, preferred = initialOffset)
460-
offsetAfterMonths = instantAfterMonths.offsetIn(timeZone)
461-
ldtAfterMonths = instantAfterMonths.toLocalDateTime(offsetAfterMonths)
462-
} else {
463-
instantAfterMonths = this@plus
464-
offsetAfterMonths = initialOffset
465-
ldtAfterMonths = initialLdt
466-
}
467-
val instantAfterMonthsAndDays = if (days != 0) {
468-
val unresolvedLdtWithDays = ldtAfterMonths.plus(days, DateTimeUnit.DAY)
469-
localDateTimeToInstant(unresolvedLdtWithDays, timeZone, preferred = offsetAfterMonths)
470-
} else {
471-
instantAfterMonths
472-
}
473-
instantAfterMonthsAndDays
453+
val ldtPlusDate = toLocalDateTimeFailing(initialOffset)
454+
.run { if (totalMonths != 0L) { plus(totalMonths, DateTimeUnit.MONTH) } else { this } }
455+
.run { if (days != 0) { this.plus(days, DateTimeUnit.DAY) } else { this } }
456+
localDateTimeToInstant(ldtPlusDate, timeZone, preferred = initialOffset)
474457
.run { if (totalNanoseconds != 0L) plus(0, totalNanoseconds).check(timeZone) else this }
475458
}.check(timeZone)
476459
} catch (e: ArithmeticException) {
@@ -522,21 +505,21 @@ public fun Instant.minus(period: DateTimePeriod, timeZone: TimeZone): Instant =
522505
public fun Instant.periodUntil(other: Instant, timeZone: TimeZone): DateTimePeriod {
523506
val initialOffset = offsetIn(timeZone)
524507
val initialLdt = toLocalDateTimeFailing(initialOffset)
525-
val otherLdt = other.toLocalDateTimeFailing(other.offsetIn(timeZone))
526-
527-
val months = initialLdt.until(otherLdt, DateTimeUnit.MONTH) // `until` on dates never fails
528-
val unresolvedLdtWithMonths = initialLdt.plus(months, DateTimeUnit.MONTH)
529-
// won't throw: thisLdt + months <= otherLdt, which is known to be valid
530-
val instantWithMonths = localDateTimeToInstant(unresolvedLdtWithMonths, timeZone, preferred = initialOffset)
531-
val offsetWithMonths = instantWithMonths.offsetIn(timeZone)
532-
val ldtWithMonths = instantWithMonths.toLocalDateTime(offsetWithMonths)
533-
val days = ldtWithMonths.until(otherLdt, DateTimeUnit.DAY) // `until` on dates never fails
534-
val unresolvedLdtWithDays = ldtWithMonths.plus(days, DateTimeUnit.DAY)
508+
val otherLdt = other.toLocalDateTimeFailing(timeZone)
509+
val timeAfterAddingDate =
510+
localDateTimeToInstant(otherLdt.date.atTime(initialLdt.time), timeZone, preferred = initialOffset)
511+
val delta = when {
512+
other > this && timeAfterAddingDate > other -> -1 // addition won't throw: end date - date >= 1
513+
other < this && timeAfterAddingDate < other -> 1 // addition won't throw: date - end date >= 1
514+
else -> 0
515+
}
516+
val endDate = otherLdt.date.plus(delta, DateTimeUnit.DAY) // `endDate` is guaranteed to be valid
517+
val unresolvedLdtWithDays = endDate.atTime(initialLdt.time)
535518
val newInstant = localDateTimeToInstant(unresolvedLdtWithDays, timeZone, preferred = initialOffset)
536519
// won't throw: thisLdt + days <= otherLdt
537520
val nanoseconds = newInstant.until(other, DateTimeUnit.NANOSECOND) // |otherLdt - thisLdt| < 24h
538-
539-
return buildDateTimePeriod(months, days.toInt(), nanoseconds)
521+
val datePeriod = endDate - initialLdt.date
522+
return buildDateTimePeriod(datePeriod.totalMonths, datePeriod.days, nanoseconds)
540523
}
541524

542525
/**
@@ -555,8 +538,18 @@ public fun Instant.periodUntil(other: Instant, timeZone: TimeZone): DateTimePeri
555538
*/
556539
public fun Instant.until(other: Instant, unit: DateTimeUnit, timeZone: TimeZone): Long =
557540
when (unit) {
558-
is DateTimeUnit.DateBased ->
559-
toLocalDateTimeFailing(offsetIn(timeZone)).until(other.toLocalDateTimeFailing(other.offsetIn(timeZone)), unit)
541+
is DateTimeUnit.DateBased -> {
542+
val start = toLocalDateTimeFailing(timeZone)
543+
val end = other.toLocalDateTimeFailing(timeZone)
544+
val timeAfterAddingDate =
545+
localDateTimeToInstant(end.date.atTime(start.time), timeZone, preferred = this.offsetIn(timeZone))
546+
val delta = when {
547+
other > this && timeAfterAddingDate > other -> -1 // addition won't throw: end date - date >= 1
548+
other < this && timeAfterAddingDate < other -> 1 // addition won't throw: date - end date >= 1
549+
else -> 0
550+
}
551+
start.date.until(end.date.plus(delta, DateTimeUnit.DAY), unit)
552+
}
560553
is DateTimeUnit.TimeBased -> {
561554
check(timeZone); other.check(timeZone)
562555
until(other, unit)
@@ -890,11 +883,14 @@ private fun Instant.toLocalDateTimeFailing(offset: UtcOffset): LocalDateTime = t
890883
throw DateTimeArithmeticException("Can not convert instant $this to LocalDateTime to perform computations", e)
891884
}
892885

886+
private fun Instant.toLocalDateTimeFailing(timeZone: TimeZone): LocalDateTime =
887+
toLocalDateTimeFailing(offsetIn(timeZone))
888+
893889
/** Check that [Instant] fits in [LocalDateTime].
894890
* This is done on the results of computations for consistency with other platforms.
895891
*/
896892
private fun Instant.check(zone: TimeZone): Instant = this@check.also {
897-
toLocalDateTimeFailing(offsetIn(zone))
893+
toLocalDateTimeFailing(zone)
898894
}
899895

900896
private fun LocalDateTime.plus(value: Long, unit: DateTimeUnit.DateBased) =
@@ -908,18 +904,3 @@ private fun LocalDateTime.plus(value: Int, unit: DateTimeUnit.DateBased) =
908904
* @throws IllegalArgumentException if the boundaries of Instant are overflown
909905
*/
910906
internal expect fun Instant.plus(secondsToAdd: Long, nanosToAdd: Long): Instant
911-
912-
// org.threeten.bp.LocalDateTime#until
913-
internal fun LocalDateTime.until(other: LocalDateTime, unit: DateTimeUnit.DateBased): Long {
914-
val otherDate = other.date
915-
val delta = when {
916-
otherDate > date && other.time < time -> -1 // addition won't throw: endDate - date >= 1
917-
otherDate < date && other.time > time -> 1 // addition won't throw: date - endDate >= 1
918-
else -> 0
919-
}
920-
val endDate = otherDate.plus(delta, DateTimeUnit.DAY)
921-
return when (unit) {
922-
is DateTimeUnit.MonthBased -> date.until(endDate, DateTimeUnit.MONTH) / unit.months
923-
is DateTimeUnit.DayBased -> date.until(endDate, DateTimeUnit.DAY) / unit.days
924-
}
925-
}

core/common/test/InstantTest.kt

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import kotlin.time.Duration.Companion.hours
1616
import kotlin.time.Duration.Companion.milliseconds
1717
import kotlin.time.Duration.Companion.nanoseconds
1818
import kotlin.time.Duration.Companion.seconds
19+
import kotlin.time.TimeSource
1920

2021
class InstantTest {
2122

@@ -282,6 +283,104 @@ class InstantTest {
282283
assertEquals(end, end2)
283284
}
284285

286+
@Test
287+
fun periodUntilSameSign() {
288+
val tz = TimeZone.of("Europe/Berlin")
289+
// Same sign when the period is positive but smaller than the non-DST-aware date-based period
290+
assertPeriodSameSign(
291+
LocalDateTime(2025, 3, 29, 2, 30).toInstant(tz).periodUntil(
292+
LocalDateTime(2025, 3, 30, 3, 10).toInstant(tz), tz))
293+
// Same sign when the period is negative but bigger than the non-DST-aware date-based period
294+
assertPeriodSameSign(
295+
Instant.parse("2025-07-27T00:59:00Z").periodUntil(
296+
Instant.parse("2024-10-27T01:00:00Z"), tz))
297+
}
298+
299+
@Test
300+
@Ignore
301+
fun periodUntilSameSignStressTest() {
302+
val tz = TimeZone.of("Europe/Berlin")
303+
val endMoment = TimeSource.Monotonic.markNow() + STRESS_TEST_DURATION
304+
while (endMoment.elapsedNow().isNegative()) {
305+
val start = Instant.fromEpochSeconds(Random.nextLong(1700000000, 1767222000))
306+
val end = Instant.fromEpochSeconds(Random.nextLong(1700000000, 1767222000))
307+
val period = start.periodUntil(end, tz)
308+
assertPeriodSameSign(period)
309+
}
310+
}
311+
312+
@Test
313+
fun untilDays() {
314+
val tz = TimeZone.of("Europe/Berlin")
315+
// No overshooting when the distance is positive but smaller than the non-DST-aware date-based distance
316+
run {
317+
val i1 = LocalDateTime(2025, 3, 29, 2, 30).toInstant(tz)
318+
val i2 = LocalDateTime(2025, 3, 30, 3, 10).toInstant(tz)
319+
val distance = i1.until(i2, DateTimeUnit.DAY, tz)
320+
assertTrue(i2 > i1.plus(distance, DateTimeUnit.DAY, tz))
321+
}
322+
// No overshooting when the distance is negative but bigger than the non-DST-aware date-based distance
323+
run {
324+
val i1 = Instant.parse("2025-07-27T00:59:00Z")
325+
val i2 = Instant.parse("2024-10-27T01:00:00Z")
326+
val distance = i1.until(i2, DateTimeUnit.DAY, tz)
327+
assertTrue(i2 < i1.plus(distance, DateTimeUnit.DAY, tz))
328+
}
329+
}
330+
331+
@Test
332+
@Ignore
333+
fun untilDaysStressTest() {
334+
val tz = TimeZone.of("Europe/Berlin")
335+
val endMoment = TimeSource.Monotonic.markNow() + STRESS_TEST_DURATION
336+
while (endMoment.elapsedNow().isNegative()) {
337+
val start = Instant.fromEpochSeconds(Random.nextLong(1700000000, 1767222000))
338+
val end = Instant.fromEpochSeconds(Random.nextLong(1700000000, 1767222000))
339+
val period = start.until(end, DateTimeUnit.DAY, tz)
340+
val afterAdding = start.plus(period, DateTimeUnit.DAY, tz)
341+
if (afterAdding > end && start < end || afterAdding < end && start > end) {
342+
error("start: $start (${start.toLocalDateTime(tz)}), end: $end (${end.toLocalDateTime(tz)}), period: $period, " +
343+
"afterAdding: $afterAdding (${afterAdding.toLocalDateTime(tz)})")
344+
}
345+
}
346+
}
347+
348+
@Test
349+
fun dateTimePeriodWithGapBetweenMonthsAndDays() {
350+
val zone = TimeZone.of("America/New_York")
351+
// LocalDateTime(2019, 3, 10, 2, 0) is a gap.
352+
// If months and days are not added atomically, the result will be adjusted.
353+
val start = Instant.parse("2019-02-10T02:00:00-05:00")
354+
val expectedEnd = Instant.parse("2019-03-11T02:00:00-04:00")
355+
val end = start.plus(DateTimePeriod(months = 1, days = 1), zone)
356+
// assertEquals(expectedEnd, end)
357+
val period = start.periodUntil(end, zone)
358+
assertEquals(end, start.plus(period, zone))
359+
}
360+
361+
@Test
362+
fun periodUntilWithGapBetweenMonthsAndDays() {
363+
val start = Instant.parse("2024-01-30T01:10:00Z")
364+
val end = Instant.parse("2025-04-01T01:10:00Z")
365+
val tz = TimeZone.of("Europe/Berlin")
366+
val period = start.periodUntil(end, tz)
367+
assertEquals(DateTimePeriod(years = 1, months = 2, days = 2, hours = 1), period)
368+
assertEquals(end, start.plus(period, tz), "start: $start, end: $end, period: $period")
369+
}
370+
371+
@Test
372+
@Ignore
373+
fun periodUntilWithGapBetweenMonthsAndDaysStressTest() {
374+
val tz = TimeZone.of("Europe/Berlin")
375+
val endMoment = TimeSource.Monotonic.markNow() + STRESS_TEST_DURATION
376+
while (endMoment.elapsedNow().isNegative()) {
377+
val start = Instant.fromEpochSeconds(Random.nextLong(1700000000, 1767222000))
378+
val end = Instant.fromEpochSeconds(Random.nextLong(1700000000, 1767222000))
379+
val period = start.periodUntil(end, tz)
380+
assertEquals(end, start.plus(period, tz), "start: $start, end: $end, period: $period")
381+
}
382+
}
383+
285384
@Test
286385
fun diffInvariant() {
287386
repeat(STRESS_TEST_ITERATIONS) {
@@ -445,6 +544,12 @@ class InstantTest {
445544
assertFalse(Instant.MIN.isDistantFuture)
446545
}
447546

547+
private fun DateTimePeriod.hasSameSign() =
548+
totalMonths >= 0 && days >= 0 && totalNanoseconds >= 0 ||
549+
totalMonths <= 0 && days <= 0 && totalNanoseconds <= 0
550+
private fun assertPeriodSameSign(period: DateTimePeriod) {
551+
assertTrue(period.hasSameSign(), "Period $period has different signs for months, days and nanoseconds")
552+
}
448553
}
449554

450555
class InstantRangeTest {

core/common/test/assertions.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import kotlinx.datetime.DateTimeArithmeticException
88
import kotlinx.datetime.DateTimeFormatException
99
import kotlin.test.assertFailsWith
1010
import kotlin.test.fail
11+
import kotlin.time.Duration.Companion.minutes
1112

1213
@Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")
1314
@kotlin.internal.InlineOnly
@@ -39,4 +40,9 @@ inline fun <T> assertIllegalArgument(message: String? = null, f: () -> T) {
3940
/**
4041
* The number of iterations to perform in nondeterministic tests.
4142
*/
42-
const val STRESS_TEST_ITERATIONS = 1000
43+
const val STRESS_TEST_ITERATIONS = 1000
44+
45+
/**
46+
* How long to spin nondeterministic tests before giving up.
47+
*/
48+
val STRESS_TEST_DURATION = 5.minutes

0 commit comments

Comments
 (0)