diff --git a/modules/core/src/main/scala/eu/timepit/fs2cron/ZonedDateTimeScheduler.scala b/modules/core/src/main/scala/eu/timepit/fs2cron/ZonedDateTimeScheduler.scala index 6102dc96..0e5b6c9e 100644 --- a/modules/core/src/main/scala/eu/timepit/fs2cron/ZonedDateTimeScheduler.scala +++ b/modules/core/src/main/scala/eu/timepit/fs2cron/ZonedDateTimeScheduler.scala @@ -17,7 +17,7 @@ package eu.timepit.fs2cron import cats.effect.{Sync, Temporal} -import cats.syntax.all._ +import cats.syntax.all.* import java.time.temporal.ChronoUnit import java.time.{Instant, ZoneId, ZoneOffset, ZonedDateTime} @@ -29,14 +29,20 @@ abstract class ZonedDateTimeScheduler[F[_], Schedule](zoneId: F[ZoneId])(implici ) extends Scheduler[F, Schedule] { def next(from: ZonedDateTime, schedule: Schedule): F[ZonedDateTime] - override def fromNowUntilNext(schedule: Schedule): F[FiniteDuration] = - now.flatMap { from => - next(from, schedule).map { to => - val durationInMillis = from.until(to, ChronoUnit.MILLIS) - FiniteDuration(durationInMillis, TimeUnit.MILLISECONDS) - } + def durationUntilNext(from: ZonedDateTime, schedule: Schedule): F[FiniteDuration] = + next(from, schedule).map { to => + val durationInMillis = from + // Since `until` only returns complete units between `from` and `to`, + // we truncate `from` to milliseconds, so that `durationInMillis` + `from` + // is never before `to`. See https://github.com/fthomas/fs2-cron/issues/598. + .truncatedTo(ChronoUnit.MILLIS) + .until(to, ChronoUnit.MILLIS) + FiniteDuration(durationInMillis, TimeUnit.MILLISECONDS) } + override def fromNowUntilNext(schedule: Schedule): F[FiniteDuration] = + now.flatMap(from => durationUntilNext(from, schedule)) + private val now: F[ZonedDateTime] = (temporal.realTime, zoneId).mapN((d, z) => Instant.EPOCH.plusNanos(d.toNanos).atZone(z)) } diff --git a/modules/core/src/test/scala/eu/timepit/fs2cron/ZonedDateTimeSchedulerSuite.scala b/modules/core/src/test/scala/eu/timepit/fs2cron/ZonedDateTimeSchedulerSuite.scala index 586b7a9c..901a7b0b 100644 --- a/modules/core/src/test/scala/eu/timepit/fs2cron/ZonedDateTimeSchedulerSuite.scala +++ b/modules/core/src/test/scala/eu/timepit/fs2cron/ZonedDateTimeSchedulerSuite.scala @@ -20,7 +20,9 @@ import cats.effect.IO import fs2.Stream import munit.CatsEffectSuite -import java.time.{Instant, ZoneId, ZoneOffset} +import java.time.{Instant, ZoneId, ZoneOffset, ZonedDateTime} +import java.util.concurrent.TimeUnit +import scala.concurrent.duration.FiniteDuration trait ZonedDateTimeSchedulerSuite[Schedule] extends CatsEffectSuite { def schedulerCompanion: ZonedDateTimeScheduler.Companion[Schedule] @@ -76,4 +78,20 @@ trait ZonedDateTimeSchedulerSuite[Schedule] extends CatsEffectSuite { val s2 = s1.map(instantSeconds).take(2).forall(!isEven(_)) assertIO(s2.compile.last, Some(true)) } + + test("durationUntilNext: from + duration >= next") { + val scheduler = schedulerUtc.asInstanceOf[ZonedDateTimeScheduler[IO, Schedule]] + + val from = ZonedDateTime.of(1970, 1, 1, 0, 0, 0, 100000, ZoneOffset.UTC) + val duration = scheduler.durationUntilNext(from, everySecond) + + val next = scheduler.next(from, everySecond) + val fromPlusDuration = duration.map(d => from.plusNanos(d.toNanos)) + val fromPlusDurationGtEqNext = fromPlusDuration.flatMap(fpd => next.map(n => !fpd.isBefore(n))) + + for { + _ <- assertIO(duration, FiniteDuration(1000L, TimeUnit.MILLISECONDS)) + _ <- assertIOBoolean(fromPlusDurationGtEqNext) + } yield () + } }