Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions app/uk/gov/hmrc/eacdfileprocessor/config/CronExpressionParser.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/*
* Copyright 2026 HM Revenue & Customs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package uk.gov.hmrc.eacdfileprocessor.config

import java.time._
import java.time.temporal.ChronoUnit
import scala.annotation.tailrec

sealed trait IntRule {
def matches(v: Int): Boolean
}
final case class AnyRule() extends IntRule {
override def matches(v: Int): Boolean = true
}
final case class ExactRule(v: Int) extends IntRule {
override def matches(x: Int): Boolean = x == v
}
final case class ListRule(values: Set[Int]) extends IntRule {
override def matches(x: Int): Boolean = values.contains(x)
}
final case class RangeRule(start: Int, end: Int) extends IntRule {
override def matches(x: Int): Boolean = x >= start && x <= end
}
final case class StepRule(start: Int, step: Int) extends IntRule {
override def matches(x: Int): Boolean = x >= start && ((x - start) % step == 0)
}

final case class CronSpec(
seconds: Set[Int],
minutes: IntRule,
hours: IntRule,
daysOfWeek: Option[Set[DayOfWeek]]
) {
def matches(dt: ZonedDateTime): Boolean = {
seconds.contains(dt.getSecond) &&
minutes.matches(dt.getMinute) &&
hours.matches(dt.getHour) &&
daysOfWeek.forall(_.contains(dt.getDayOfWeek))
}

/** Find next run strictly after `from`. */
def nextAfter(from: ZonedDateTime, maxSearchDays: Int = 400): ZonedDateTime = {
val end = from.plusDays(maxSearchDays.toLong)

@tailrec
def loop(cursor: ZonedDateTime): ZonedDateTime = {
if (cursor.isAfter(end)) {
throw new IllegalArgumentException(s"No next run found within $maxSearchDays days for cron: $this")
}
if (matches(cursor)) cursor else loop(cursor.plusSeconds(1))
}

loop(from.plusSeconds(1).truncatedTo(ChronoUnit.SECONDS))
}
}

object CronExpressionParser {
private val dowMap: Map[String, DayOfWeek] = Map(
"MON" -> DayOfWeek.MONDAY,
"TUE" -> DayOfWeek.TUESDAY,
"WED" -> DayOfWeek.WEDNESDAY,
"THU" -> DayOfWeek.THURSDAY,
"FRI" -> DayOfWeek.FRIDAY,
"SAT" -> DayOfWeek.SATURDAY,
"SUN" -> DayOfWeek.SUNDAY
)

/**
* Supports underscore-separated 6-field cron:
* second_minute_hour_dayOfMonth_month_dayOfWeek
*
* Examples:
* 0_0/3_5-23_?_*_MON-FRI
* 0_0_9_?_*_*
* 50_0/2_*_?_*_*
*/
def parse(raw: String): CronSpec = {
val expr = raw.replace('_', ' ').trim
val parts = expr.split("\\s+")
require(parts.length == 6, s"Expected 6 fields, got ${parts.length}: $raw")

val secondField = parts(0)
val minuteField = parts(1)
val hourField = parts(2)
val dayOfMonthField = parts(3)
val monthField = parts(4)
val dayOfWeekField = parts(5)

require(dayOfMonthField == "?" || dayOfMonthField == "*",
s"Unsupported day-of-month '$dayOfMonthField' in: $raw")
require(monthField == "*", s"Unsupported month '$monthField' in: $raw")

CronSpec(
seconds = parseSecond(secondField),
minutes = parseIntRule(minuteField, 0, 59, "minute"),
hours = parseIntRule(hourField, 0, 23, "hour"),
daysOfWeek = parseDayOfWeek(dayOfWeekField)
)
}

private def parseSecond(s: String): Set[Int] = {
val v = toInt(s, "second")
require(v >= 0 && v <= 59, s"second out of range [0,59]: $s")
Set(v)
}

private def parseIntRule(s: String, min: Int, max: Int, field: String): IntRule = {
if (s == "*") {
AnyRule()
} else if (s.contains("/")) {
val arr = s.split("/", 2)
require(arr.length == 2, s"Invalid $field step format: $s")
val start = toIntInRange(arr(0), min, max, field)
val step = toInt(arr(1), field)
require(step > 0, s"$field step must be > 0: $s")
StepRule(start, step)
} else if (s.contains(",")) {
ListRule(s.split(",").map(v => toIntInRange(v, min, max, field)).toSet)
} else if (s.contains("-")) {
val arr = s.split("-", 2)
require(arr.length == 2, s"Invalid $field range format: $s")
val start = toIntInRange(arr(0), min, max, field)
val end = toIntInRange(arr(1), min, max, field)
require(start <= end, s"Invalid $field range, start > end: $s")
RangeRule(start, end)
} else {
ExactRule(toIntInRange(s, min, max, field))
}
}

private def parseDayOfWeek(s: String): Option[Set[DayOfWeek]] = {
if (s == "*" || s == "?") {
None
} else if (s.contains("-")) {
val arr = s.split("-", 2)
require(arr.length == 2, s"Invalid day-of-week range format: $s")
val start = parseDow(arr(0))
val end = parseDow(arr(1))
val all = DayOfWeek.values().toList
val startIdx = all.indexOf(start)
val endIdx = all.indexOf(end)
require(startIdx <= endIdx, s"Unsupported wrap-around day-of-week range: $s")
Some(all.slice(startIdx, endIdx + 1).toSet)
} else if (s.contains(",")) {
Some(s.split(",").map(parseDow).toSet)
} else {
Some(Set(parseDow(s)))
}
}

private def parseDow(token: String): DayOfWeek = {
dowMap.getOrElse(token.toUpperCase, throw new IllegalArgumentException(s"Invalid day-of-week: $token"))
}

private def toInt(s: String, field: String): Int = {
s.toIntOption.getOrElse(throw new IllegalArgumentException(s"Invalid $field value: $s"))
}

private def toIntInRange(s: String, min: Int, max: Int, field: String): Int = {
val v = toInt(s, field)
require(v >= min && v <= max, s"$field out of range [$min,$max]: $s")
v
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import play.api.Logging
import uk.gov.hmrc.http.client.HttpClientV2
import uk.gov.hmrc.http.{HeaderCarrier, HttpReads, HttpResponse, StringContextOps}
import uk.gov.hmrc.play.bootstrap.config.ServicesConfig

import uk.gov.hmrc.http.HttpReads.Implicits._
import javax.inject.Inject
import scala.concurrent.{ExecutionContext, Future}

Expand Down
127 changes: 57 additions & 70 deletions app/uk/gov/hmrc/eacdfileprocessor/scheduler/ScheduledJob.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@ package uk.gov.hmrc.eacdfileprocessor.scheduler
import org.apache.pekko.actor.{ActorRef, ActorSystem, Cancellable}
import org.slf4j.{Logger, LoggerFactory}
import play.api.Configuration
import uk.gov.hmrc.eacdfileprocessor.config.{CronExpressionParser, CronSpec}
import uk.gov.hmrc.eacdfileprocessor.scheduler.SchedulingActor.ScheduledMessage

import java.time.format.DateTimeParseException
import java.time.{Clock, LocalTime}
import java.time.{ZoneOffset, ZonedDateTime, Duration as JavaDuration}
import scala.concurrent.ExecutionContext
import scala.concurrent.duration.{DurationInt, FiniteDuration}
import scala.concurrent.duration.{DurationLong, FiniteDuration}
import scala.util.Try
import scala.util.control.NonFatal

trait ScheduledJob {
private[scheduler] val logger: Logger = LoggerFactory.getLogger(getClass)
Expand All @@ -38,86 +40,71 @@ trait ScheduledJob {

lazy val schedulingActorRef: ActorRef = actorSystem.actorOf(SchedulingActor.props)

lazy val enabled: Boolean = config.getOptional[Boolean](s"schedules.$jobName.enabled").getOrElse(false)
lazy val enabled: Boolean =
config.getOptional[Boolean](s"schedules.$jobName.enabled").getOrElse(false)

lazy val description: Option[String] = config.getOptional[String](s"schedules.$jobName.description")
lazy val description: Option[String] =
config.getOptional[String](s"schedules.$jobName.description")

lazy val interval: Option[FiniteDuration] = config.getOptional[FiniteDuration](s"schedules.$jobName.interval")
private[scheduler] lazy val expression: Option[String] =
config.getOptional[String](s"schedules.$jobName.expression")
.map(_.replace('_', ' ').trim)
.filter(_.nonEmpty)

lazy val startTimeUtc: Option[LocalTime] = readOptionalUtcTime("start-time-utc")
private[scheduler] lazy val cronSpec: Option[CronSpec] =
expression.flatMap { expr =>
Try(CronExpressionParser.parse(expr)).toOption.orElse {
logger.warn(s"Invalid cron expression for schedules.$jobName.expression: '$expr'")
None
}
}

lazy val endTimeUtc: Option[LocalTime] = readOptionalUtcTime("end-time-utc")
private[scheduler] def nowUtc: ZonedDateTime =
ZonedDateTime.now(ZoneOffset.UTC)

private[scheduler] lazy val utcWindow: Option[(LocalTime, LocalTime)] =
(startTimeUtc, endTimeUtc) match {
case (Some(start), Some(end)) => Some((start, end))
case _ => None
}
private[scheduler] def nextDelay(spec: CronSpec, from: ZonedDateTime): FiniteDuration = {
val nextRun = spec.nextAfter(from)
val millis = JavaDuration.between(from, nextRun).toMillis.max(0L)
millis.millis
}

private[scheduler] lazy val hasPartialUtcWindowConfig: Boolean =
(startTimeUtc.isDefined && endTimeUtc.isEmpty) || (startTimeUtc.isEmpty && endTimeUtc.isDefined)

private[scheduler] def currentUtcTime: LocalTime = LocalTime.now(Clock.systemUTC())

private[scheduler] def isWithinAllowedUtcWindow(nowUtc: LocalTime = currentUtcTime): Boolean =
utcWindow match {
// No window configured: allow all runs.
case None =>
true
// Equal bounds means full-day window.
case Some((start, end)) if start == end =>
true
// Same-day window (for example 09:00 -> 17:00).
case Some((start, end)) if start.isBefore(end) =>
val isAtOrAfterStart = !nowUtc.isBefore(start)
val isBeforeEnd = nowUtc.isBefore(end)
isAtOrAfterStart && isBeforeEnd
// Overnight window (for example 22:00 -> 05:00).
case Some((start, end)) =>
val isAtOrAfterStart = !nowUtc.isBefore(start)
val isBeforeEnd = nowUtc.isBefore(end)
isAtOrAfterStart || isBeforeEnd
private[scheduler] def triggerAndReschedule(spec: CronSpec): Unit = {
try {
logger.debug(s"Triggering scheduled job: $jobName")
schedulingActorRef ! scheduledMessage
} catch {
case NonFatal(e) =>
logger.error(s"Scheduled job $jobName failed while dispatching message", e)
} finally {
scheduleNext(spec)
}
}

private[scheduler] lazy val utcWindowSkipReason: Option[String] =
utcWindow.map((start, end) => s"outside configured UTC run window [$start, $end)")

private def readOptionalUtcTime(configKey: String): Option[LocalTime] =
config
.getOptional[String](s"schedules.$jobName.$configKey")
.flatMap { value =>
try
Some(LocalTime.parse(value))
catch {
case _: DateTimeParseException =>
logger.warn(s"Ignoring invalid UTC time for schedules.$jobName.$configKey. Expected format like HH:mm, got '$value'")
None
}
}
private[scheduler] def scheduleNext(spec: CronSpec): Cancellable = {
val from = nowUtc

private[scheduler] def scheduleAtFixedRate(every: FiniteDuration): Cancellable =
actorSystem.scheduler.scheduleAtFixedRate(
initialDelay = 0.seconds,
interval = every,
receiver = schedulingActorRef,
message = scheduledMessage
)
try {
val delay = nextDelay(spec, from)
logger.debug(s"Next run for $jobName scheduled in $delay from $from")
actorSystem.scheduler.scheduleOnce(delay) {
triggerAndReschedule(spec)
}
} catch {
case NonFatal(e) =>
logger.error(s"Failed to schedule next run for $jobName", e)
throw e
}
}

lazy val schedule: Unit = {

(enabled, interval) match {
case (true, Some(duration)) =>
if (hasPartialUtcWindowConfig) {
logger.warn(s"Ignoring UTC run window for $jobName because both start-time-utc and end-time-utc must be configured together")
}
scheduleAtFixedRate(duration)
logger.info(s"Scheduler for $jobName has been started with interval: $duration")
(enabled, cronSpec) match {
case (true, Some(spec)) =>
scheduleNext(spec)
logger.info(s"Scheduler for $jobName started with expression: ${expression.getOrElse("")}")
case (true, None) =>
logger.info(s"Scheduler for $jobName is disabled as there is no interval configured")
logger.info(s"Scheduler for $jobName is disabled as there is no valid expression configured")
case (false, _) =>
logger.info(s"Scheduler for $jobName is disabled by configuration")
}

}

}
}
Loading