diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/DateTimeUtils.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/DateTimeUtils.scala index ab43fe1362..f8d2099b9f 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/DateTimeUtils.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/DateTimeUtils.scala @@ -1,10 +1,11 @@ package com.gu.mediaservice.lib import java.time.format.DateTimeFormatter -import java.time.{Instant, ZoneId, ZonedDateTime} - +import java.time.{Instant, LocalDateTime, ZoneId, ZonedDateTime} import org.joda.time.DateTime +import java.time.temporal.ChronoUnit +import scala.concurrent.duration.{DurationLong, FiniteDuration} import scala.util.Try object DateTimeUtils { @@ -18,4 +19,14 @@ object DateTimeUtils { // TODO move this to a LocalDateTime def fromValueOrNow(value: Option[String]): DateTime = Try{new DateTime(value.get)}.getOrElse(DateTime.now) + + def timeUntilNextInterval(interval: FiniteDuration, now: ZonedDateTime = now): FiniteDuration = { + val nowRoundedDownToTheHour = now.truncatedTo(ChronoUnit.HOURS) + val millisSinceTheHour = ChronoUnit.MILLIS.between(nowRoundedDownToTheHour, now).toDouble + val numberOfIntervals = (millisSinceTheHour / interval.toMillis).ceil.toLong + ChronoUnit.MILLIS.between( + now, + nowRoundedDownToTheHour plusSeconds (interval mul numberOfIntervals).toSeconds + ).millis + } } diff --git a/common-lib/src/test/scala/com/gu/mediaservice/lib/DateTimeUtilsTest.scala b/common-lib/src/test/scala/com/gu/mediaservice/lib/DateTimeUtilsTest.scala index bbd67dc8ab..2cce5b07d7 100644 --- a/common-lib/src/test/scala/com/gu/mediaservice/lib/DateTimeUtilsTest.scala +++ b/common-lib/src/test/scala/com/gu/mediaservice/lib/DateTimeUtilsTest.scala @@ -4,6 +4,11 @@ import org.joda.time.DateTime import org.scalatest.funspec.AnyFunSpec import org.scalatest.matchers.should.Matchers +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit +import scala.concurrent.duration.{DurationInt, DurationLong, FiniteDuration} + + class DateTimeUtilsTest extends AnyFunSpec with Matchers { it ("should convert a string to a DateTime") { val dateString = "2020-01-01T12:34:56.000Z" @@ -21,4 +26,28 @@ class DateTimeUtilsTest extends AnyFunSpec with Matchers { val actual = DateTimeUtils.fromValueOrNow(None) actual shouldBe a[DateTime] } + + it ("should return the time until the next instance of the interval relative to the hour"){ + def toZonedDateTime(timePart: String) = ZonedDateTime.parse(s"2023-11-21T${timePart}Z[Europe/London]") + def test(nowTime: String, expectedTime: String, interval: FiniteDuration = 15.minutes) = { + DateTimeUtils.timeUntilNextInterval( + interval, + toZonedDateTime(nowTime) + ) shouldEqual ChronoUnit.MILLIS.between( + toZonedDateTime(nowTime), + toZonedDateTime(expectedTime) + ).millis + } + test(nowTime = "11:11:23.887", expectedTime = "11:15:00.000") + test(nowTime = "11:23:23.887", expectedTime = "11:30:00.000") + test(nowTime = "11:33:23.887", expectedTime = "11:45:00.000") + test(nowTime = "11:50:23.887", expectedTime = "12:00:00.000") + test(nowTime = "11:00:00.000", expectedTime = "11:00:00.000") + test(nowTime = "11:00:00.001", expectedTime = "11:15:00.000") + + test(nowTime = "11:00:00.001", expectedTime = "11:01:00.000", interval = 1.minute) + test(nowTime = "11:00:00.001", expectedTime = "11:02:00.000", interval = 2.minute) + + test(nowTime = "11:01:00.001", expectedTime = "12:00:00.000", interval = 1.hour) + } } diff --git a/thrall/app/controllers/ReaperController.scala b/thrall/app/controllers/ReaperController.scala index e4c860fcc8..1d6bfb19bb 100644 --- a/thrall/app/controllers/ReaperController.scala +++ b/thrall/app/controllers/ReaperController.scala @@ -1,7 +1,7 @@ package controllers import akka.actor.Scheduler -import com.gu.mediaservice.lib.ImageIngestOperations +import com.gu.mediaservice.lib.{DateTimeUtils, ImageIngestOperations} import com.gu.mediaservice.lib.auth.Permissions.DeleteImage import com.gu.mediaservice.lib.auth.{Authentication, Authorisation, BaseControllerWithLoginRedirects} import com.gu.mediaservice.lib.config.Services @@ -15,7 +15,7 @@ import org.joda.time.{DateTime, DateTimeZone} import play.api.libs.json.{JsValue, Json} import play.api.mvc.{Action, AnyContent, ControllerComponents} -import scala.concurrent.duration.DurationInt +import java.time.temporal.ChronoUnit import scala.concurrent.{ExecutionContext, Future} import scala.jdk.CollectionConverters.collectionAsScalaIterableConverter import scala.language.postfixOps @@ -50,7 +50,7 @@ class ReaperController( (config.maybeReaperBucket, config.maybeReaperCountPerRun) match { case (Some(reaperBucket), Some(countOfImagesToReap)) => scheduler.scheduleAtFixedRate( - initialDelay = 0 seconds, + initialDelay = DateTimeUtils.timeUntilNextInterval(INTERVAL), // so we always start on multiples of the interval past the hour interval = INTERVAL, ){ () => if(store.client.doesObjectExist(reaperBucket, CONTROL_FILE_NAME)) {