From 3cc4bbc9a2607a4035a9b6ecf1ea238acdee747a Mon Sep 17 00:00:00 2001 From: Frank Thomas Date: Tue, 28 Oct 2025 09:40:39 +0100 Subject: [PATCH 1/2] Reproduce off-by-one error shown in https://github.com/fthomas/fs2-cron/issues/598#issuecomment-3451693576 --- .../fs2cron/ZonedDateTimeScheduler.scala | 15 ++++++++------- .../fs2cron/ZonedDateTimeSchedulerSuite.scala | 18 +++++++++++++++++- 2 files changed, 25 insertions(+), 8 deletions(-) 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..61740aca 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,15 @@ 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.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..18922e12 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,18 @@ 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))) + + assertIO(duration, FiniteDuration(1000L, TimeUnit.MILLISECONDS)) >> + assertIOBoolean(fromPlusDurationGtEqNext) + } } From 99a470656c49f8043d6b6d1d791e7b96d3ef54ff Mon Sep 17 00:00:00 2001 From: Frank Thomas Date: Tue, 28 Oct 2025 09:54:03 +0100 Subject: [PATCH 2/2] Fix off-by-one error in fromNowUntilNext --- .../scala/eu/timepit/fs2cron/ZonedDateTimeScheduler.scala | 7 ++++++- .../eu/timepit/fs2cron/ZonedDateTimeSchedulerSuite.scala | 6 ++++-- 2 files changed, 10 insertions(+), 3 deletions(-) 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 61740aca..0e5b6c9e 100644 --- a/modules/core/src/main/scala/eu/timepit/fs2cron/ZonedDateTimeScheduler.scala +++ b/modules/core/src/main/scala/eu/timepit/fs2cron/ZonedDateTimeScheduler.scala @@ -31,7 +31,12 @@ abstract class ZonedDateTimeScheduler[F[_], Schedule](zoneId: F[ZoneId])(implici def durationUntilNext(from: ZonedDateTime, schedule: Schedule): F[FiniteDuration] = next(from, schedule).map { to => - val durationInMillis = from.until(to, ChronoUnit.MILLIS) + 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) } 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 18922e12..901a7b0b 100644 --- a/modules/core/src/test/scala/eu/timepit/fs2cron/ZonedDateTimeSchedulerSuite.scala +++ b/modules/core/src/test/scala/eu/timepit/fs2cron/ZonedDateTimeSchedulerSuite.scala @@ -89,7 +89,9 @@ trait ZonedDateTimeSchedulerSuite[Schedule] extends CatsEffectSuite { val fromPlusDuration = duration.map(d => from.plusNanos(d.toNanos)) val fromPlusDurationGtEqNext = fromPlusDuration.flatMap(fpd => next.map(n => !fpd.isBefore(n))) - assertIO(duration, FiniteDuration(1000L, TimeUnit.MILLISECONDS)) >> - assertIOBoolean(fromPlusDurationGtEqNext) + for { + _ <- assertIO(duration, FiniteDuration(1000L, TimeUnit.MILLISECONDS)) + _ <- assertIOBoolean(fromPlusDurationGtEqNext) + } yield () } }