From 86e7f6ca8e94c341ad8fe15c5649e7ce83bca182 Mon Sep 17 00:00:00 2001 From: Matt Spataro Date: Wed, 14 Jun 2023 21:36:07 -0600 Subject: [PATCH 1/2] add guardrails for monitoring --- .../admin/controllers/TriggerFormHelper.scala | 126 ++++++++---- .../piezo/admin/utils/CronHelper.scala | 185 ++++++++++++++++++ .../piezo/admin/util/CronHelperTest.scala | 95 +++++++++ 3 files changed, 369 insertions(+), 37 deletions(-) create mode 100644 admin/app/com/lucidchart/piezo/admin/utils/CronHelper.scala create mode 100644 admin/test/com/lucidchart/piezo/admin/util/CronHelperTest.scala diff --git a/admin/app/com/lucidchart/piezo/admin/controllers/TriggerFormHelper.scala b/admin/app/com/lucidchart/piezo/admin/controllers/TriggerFormHelper.scala index a12a312..bafafe8 100644 --- a/admin/app/com/lucidchart/piezo/admin/controllers/TriggerFormHelper.scala +++ b/admin/app/com/lucidchart/piezo/admin/controllers/TriggerFormHelper.scala @@ -2,16 +2,20 @@ package com.lucidchart.piezo.admin.controllers import com.lucidchart.piezo.TriggerMonitoringPriority import com.lucidchart.piezo.TriggerMonitoringPriority.TriggerMonitoringPriority +import com.lucidchart.piezo.admin.utils.CronHelper import java.text.ParseException import org.quartz._ -import play.api.data.Form +import play.api.data.{Form, FormError} import play.api.data.Forms._ +import play.api.data.format.Formats.parsing +import play.api.data.format.Formatter import play.api.data.validation.{Constraint, Invalid, Valid, ValidationError} class TriggerFormHelper(scheduler: Scheduler) extends JobDataHelper { private def simpleScheduleFormApply(repeatCount: Int, repeatInterval: Int): SimpleScheduleBuilder = { - SimpleScheduleBuilder.simpleSchedule() + SimpleScheduleBuilder + .simpleSchedule() .withRepeatCount(repeatCount) .withIntervalInSeconds(repeatInterval) } @@ -41,14 +45,14 @@ class TriggerFormHelper(scheduler: Scheduler) extends JobDataHelper { cron: Option[CronScheduleBuilder], jobDataMap: Option[JobDataMap], triggerMonitoringPriority: String, - triggerMaxErrorTime: Int + triggerMaxErrorTime: Int, ): (Trigger, TriggerMonitoringPriority, Int) = { - val newTrigger: Trigger = TriggerBuilder.newTrigger() + val newTrigger: Trigger = TriggerBuilder + .newTrigger() .withIdentity(name, group) .withDescription(description) - .withSchedule( - triggerType match { - case "cron" => cron.get + .withSchedule(triggerType match { + case "cron" => cron.get case "simple" => simple.get }) .forJob(jobName, jobGroup) @@ -57,23 +61,24 @@ class TriggerFormHelper(scheduler: Scheduler) extends JobDataHelper { (newTrigger, TriggerMonitoringPriority.withName(triggerMonitoringPriority), triggerMaxErrorTime) } - private def triggerFormUnapply(tp: (Trigger, TriggerMonitoringPriority, Int)): - Option[ - ( - String, - String, - String, - String, - String, - String, - Option[SimpleScheduleBuilder], - Option[CronScheduleBuilder], - Option[JobDataMap], String, Int - ) - ] = { + private def triggerFormUnapply(tp: (Trigger, TriggerMonitoringPriority, Int)): Option[ + ( + String, + String, + String, + String, + String, + String, + Option[SimpleScheduleBuilder], + Option[CronScheduleBuilder], + Option[JobDataMap], + String, + Int, + ), + ] = { val trigger = tp._1 val (triggerType: String, simple, cron) = trigger match { - case cron: CronTrigger => ("cron", None, Some(cron.getScheduleBuilder)) + case cron: CronTrigger => ("cron", None, Some(cron.getScheduleBuilder)) case simple: SimpleTrigger => ("simple", Some(simple.getScheduleBuilder), None) } val description = if (trigger.getDescription() == null) "" else trigger.getDescription() @@ -89,8 +94,8 @@ class TriggerFormHelper(scheduler: Scheduler) extends JobDataHelper { cron.asInstanceOf[Option[CronScheduleBuilder]], Some(trigger.getJobDataMap), tp._2.toString, - tp._3 - ) + tp._3, + ), ) } @@ -119,6 +124,11 @@ class TriggerFormHelper(scheduler: Scheduler) extends JobDataHelper { } } + def greaterThan(greaterThanValue: Int): Constraint[Int] = Constraint[Int]("constraints.greaterThan") { value => + if (value > greaterThanValue) Valid + else Invalid(ValidationError(s"Value must be greater than $greaterThanValue")) + } + def buildTriggerForm = Form[(Trigger, TriggerMonitoringPriority, Int)]( mapping( "triggerType" -> nonEmptyText(), @@ -127,20 +137,62 @@ class TriggerFormHelper(scheduler: Scheduler) extends JobDataHelper { "jobGroup" -> nonEmptyText(), "jobName" -> nonEmptyText(), "description" -> text(), - "simple" -> optional(mapping( - "repeatCount" -> number(), - "repeatInterval" -> number() - )(simpleScheduleFormApply)(simpleScheduleFormUnapply)), - "cron" -> optional(mapping( - "cronExpression" -> nonEmptyText().verifying(validCronExpression) - )(cronScheduleFormApply)(cronScheduleFormUnapply)), + "simple" -> optional( + mapping( + "repeatCount" -> number(), + "repeatInterval" -> number(), + )(simpleScheduleFormApply)(simpleScheduleFormUnapply), + ), + "cron" -> optional( + mapping( + "cronExpression" -> nonEmptyText().verifying(validCronExpression), + )(cronScheduleFormApply)(cronScheduleFormUnapply), + ), "job-data-map" -> jobDataMap, "triggerMonitoringPriority" -> nonEmptyText(), - "triggerMaxErrorTime" -> number() - )(triggerFormApply)(triggerFormUnapply).verifying("Job does not exist", fields => { - scheduler.checkExists(fields._1.getJobKey) - }).verifying("Max time between successes must be greater than 0", fields => { - fields._3 > 0 - }) + "triggerMaxErrorTime" -> of(MaxSecondsBetweenSuccessesFormatter).verifying(greaterThan(0)), + )(triggerFormApply)(triggerFormUnapply) + .verifying( + "Job does not exist", + fields => { + scheduler.checkExists(fields._1.getJobKey) + }, + ) + .verifying( + "Max time between successes must be greater than 0", + fields => { + fields._3 > 0 + }, + ), ) } + +object MaxSecondsBetweenSuccessesFormatter extends Formatter[Int] { + override val format = Some(("format.triggerMaxErrorTime", Nil)) + override def bind(key: String, data: Map[String, String]): Either[Seq[FormError], Int] = { + for { + maxSecondsBetweenSuccesses <- parsing(_.toInt, "Numeric value expected", Nil)(key, data) + maxIntervalTime <- { + if (data.contains("cron.cronExpression")) { + parsing(expr => CronHelper.getMaxInterval(new CronExpression(expr)), "try again.", Nil)( + "cron.cronExpression", + data, + ) + } else { + parsing(_.toLong, "try again.", Nil)("simple.repeatInterval", data) + } + } + _ <- Either.cond( + maxSecondsBetweenSuccesses > maxIntervalTime, + maxSecondsBetweenSuccesses, + List( + FormError( + "triggerMaxErrorTime", + s"Must be greater than the maximum trigger interval ($maxIntervalTime seconds)", + ), + ), + ) + } yield maxSecondsBetweenSuccesses + } + override def unbind(key: String, value: Int) = Map(key -> value.toString) +} diff --git a/admin/app/com/lucidchart/piezo/admin/utils/CronHelper.scala b/admin/app/com/lucidchart/piezo/admin/utils/CronHelper.scala new file mode 100644 index 0000000..01a0627 --- /dev/null +++ b/admin/app/com/lucidchart/piezo/admin/utils/CronHelper.scala @@ -0,0 +1,185 @@ +package com.lucidchart.piezo.admin.utils + +import java.util.{Date, TimeZone} +import org.quartz.{CronExpression, CronScheduleBuilder, CronTrigger, TriggerBuilder} +import play.api.Logging +import scala.util.control.NonFatal + +object CronHelper extends Logging { + val DEFAULT_MAX_INTERVAL = 0 + val NUM_COMPLEX_SAMPLES = 8000 + + /** + * Determines the max interval for a cron expression. The operation fails silently because it's not crucial. + * @param cronExpression + */ + def getMaxInterval(cronExpression: CronExpression): Long = getMaxInterval(cronExpression, None) + def getMaxInterval(cronExpression: CronExpression, timeZone: Option[TimeZone]): Long = { + try { + val subexpressions = cronExpression.getCronExpression.split("\\s") + val isComplex = !subexpressions.drop(3).forall(expr => expr == "*" || expr == "?") + if (isComplex) getComplexMaxInterval(subexpressions, timeZone) else getSimpleMaxInterval(subexpressions, timeZone) + } catch { + case NonFatal(e) => + logger.error("Failed to validate cron expression", e) + DEFAULT_MAX_INTERVAL + } + } + + /** + * Determines the max interval by deduction. Is only used when seconds, minutes, and hours are specified. If days, + * months, or years are specified, use getComplexMaxInterval instead. + */ + private def getSimpleMaxInterval(subexpressions: Array[String], timeZone: Option[TimeZone]): Long = { + val cronContainers = getCronContainers(subexpressions.take(3), timeZone) // seconds, minutes, hours + val outermostContainer = cronContainers.findLast(!_.areAllUnitsMarked) + outermostContainer.fold(1L) { outermost => + val innerContainers = cronContainers.takeWhile(_.unitType != outermost.unitType) + val innerIntervalSeconds = innerContainers.map(_.wrapIntervalInSeconds).sum + outermost.maxIntervalInSeconds + innerIntervalSeconds + } + } + + /** + * Estimates the max interval using a combination of deduction (for the seconds and minutes), and sampling (for + * everything else). We remove the seconds and minutes from the cron expression that is used for sampling so we can + * effectively decrease the number of samples needed for a good estimate. + */ + private def getComplexMaxInterval(subexpressions: Array[String], timeZone: Option[TimeZone]): Long = { + val (secondsAndMinutes, everythingElse) = subexpressions.splitAt(2) + + // set up the dummy trigger + val simplifiedComplexCron = everythingElse.mkString(s"0 0 ", " ", "") + val selectedTimeZone = timeZone.getOrElse(TimeZone.getDefault) + val cronSchedule = CronScheduleBuilder.cronSchedule(simplifiedComplexCron).inTimeZone(selectedTimeZone) + val dummyTrigger: CronTrigger = TriggerBuilder.newTrigger().withSchedule(cronSchedule).build() + val initialFireTime = Option(dummyTrigger.getFireTimeAfter(new Date())) + + // get the interval + initialFireTime.fold(Long.MaxValue) { initialFireTime => + logger.debug(s"sample cron expression: $simplifiedComplexCron") + val sampledMaxIntervalInSeconds = getSampledMaxInterval(initialFireTime, NUM_COMPLEX_SAMPLES, dummyTrigger) + val innerMaxIntervalSeconds = getCronContainers(secondsAndMinutes, timeZone).map(_.wrapIntervalInSeconds).sum + // subtract an hour to avoid double counting innerMaxIntervalSeconds + sampledMaxIntervalInSeconds - HourUnitType.secondsPerUnit + innerMaxIntervalSeconds + } + } + + /** + * Estimates the max interval for a given cron expression. The more samples, the better the estimate. + */ + private def getSampledMaxInterval(prev: Date, numSamples: Long, trigger: CronTrigger, maxInterval: Long = 0): Long = { + Option(trigger.getFireTimeAfter(prev)) match { + case Some(next) if numSamples > 0 => + val intervalInSeconds = next.toInstant.getEpochSecond - prev.toInstant.getEpochSecond + if (intervalInSeconds > maxInterval) { + val sampleId = NUM_COMPLEX_SAMPLES - numSamples + logger.debug(s"Seconds:$intervalInSeconds Sample:$sampleId Interval:$prev -> $next") + } + getSampledMaxInterval(next, numSamples - 1, trigger, Math.max(intervalInSeconds, maxInterval)) + case _ => maxInterval + } + } + + private def getCronContainers(parts: Array[String], timeZone: Option[TimeZone]): List[CronContainer] = { + parts + .zip(List(SecondUnitType, MinuteUnitType, HourUnitType)) + .map { case (str, cronType) => CronContainer(str, cronType, timeZone) } + .toList + } + +} + +sealed abstract class UnitType(val secondsPerUnit: Long, val numUnitsInContainer: Long) +case object SecondUnitType extends UnitType(1, 60) +case object MinuteUnitType extends UnitType(60, 60) +case object HourUnitType extends UnitType(3600, 24) + +/** + * A cron container is composed of "units". The "unitType" determines how many units are in the container, and how many + * seconds are in each unit. "Marked units" describe when the trigger is set to fire. "Intervals" describe the number of + * units between (but not including) marked units. + */ +case class CronContainer(str: String, unitType: UnitType, markedUnits: List[Long], timeZone: TimeZone) { + lazy val areAllUnitsMarked: Boolean = markedUnits.size == unitType.numUnitsInContainer + + // the "wrap interval" is the interval that wraps around both ends of the container + private lazy val wrapInterval = (unitType.numUnitsInContainer - markedUnits.last) + markedUnits.head + lazy val wrapIntervalInSeconds: Long = unitsToSeconds(wrapInterval) + + // the max interval is the longest interval between any two marked units in the container, including the wrap interval + lazy val maxIntervalInSeconds: Long = { + val otherIntervals = markedUnits.zipWithIndex + .take(markedUnits.size - 1) + .map { case (value, index) => markedUnits(index + 1) - value } + val units = (otherIntervals :+ wrapInterval).max + val unitsWithDaylightSavings = units + (if (unitType == HourUnitType) getDaylightSavings(units, markedUnits) else 0) + unitsToSeconds(unitsWithDaylightSavings) + } + + /** + * We multiply the number of units by the number of seconds in a single unit. We also subtract 1 unit to avoid double + * counting the seconds in sub-intervals that are within a single unit. For example: + * - We subtract 1 hour when counting hours because the minutes make up the last hour + * - We subtract 1 minute when counting minutes because the seconds make up the last minute + * - We don't subtract 1 second when counting seconds because there are no smaller units + */ + private def unitsToSeconds(units: Long): Long = { + if (unitType == SecondUnitType) units else (units - 1) * unitType.secondsPerUnit + } + + /** + * Returns the number of seconds to add to the interval if daylight savings is being observed. + */ + private def getDaylightSavings(currentNumUnits: Long, hourInstants: List[Long]): Long = { + if (timeZone.observesDaylightTime()) { + val extraSpringForwardHours = if (hourInstants.contains(2)) { + val allBut2am = hourInstants.filter(_ != 2) // when springing forward, 2am is skipped for a day + if (allBut2am.isEmpty) { + 23 // if we only trigger at 2am and 2am is skipped, we won't trigger for another 23 hours + } else { + // calculate the unique wrap interval when we spring forward + // (number of hours remaining in the day, plus the number of hours until the first trigger in the next day) + (24 - allBut2am.last) + (allBut2am.head - 1) - currentNumUnits + } + } else -1 + val extraFallbackHours = 1 // we always gain an extra hour when falling back (1am is delayed until 2am) + Math.max(extraFallbackHours, extraSpringForwardHours) + } else 0 + } + +} + +object CronContainer { + def apply(str: String, cronType: UnitType, timeZone: Option[TimeZone]): CronContainer = { + val markedUnits = CronContainer.getMarkedUnits(str, cronType.numUnitsInContainer) + CronContainer(str, cronType, markedUnits, timeZone.getOrElse(TimeZone.getDefault)) + } + + def getMarkedUnits(str: String, unitsInContainer: Long): List[Long] = { + val slash = """(\d{1,2}|\*)/(\d{1,2})""".r + val dash = """(\d{1,2})-(\d{1,2})""".r + str match { + case "*" => (for (i <- 0L until unitsInContainer) yield i).toList + case slash(start, interval) => { + val startInt = if (start == "*") 0 else start.toLong + getMarkedUnitsForSlash(startInt, interval.toLong, unitsInContainer, List(startInt)) + } + case dash(first, second) => + val smallest = Math.min(first.toLong, second.toLong) + val largest = Math.max(first.toLong, second.toLong) + (for (i <- smallest to largest) yield i).toList + case _ => str.split(",").map(_.toLong).toList.sorted + } + } + + private def getMarkedUnitsForSlash( + start: Long, + interval: Long, + unitsInContainer: Long, + result: List[Long] = Nil, + ): List[Long] = { + if (start + interval >= unitsInContainer) result + else getMarkedUnitsForSlash(start + interval, interval, unitsInContainer, result :+ (start + interval)) + } +} diff --git a/admin/test/com/lucidchart/piezo/admin/util/CronHelperTest.scala b/admin/test/com/lucidchart/piezo/admin/util/CronHelperTest.scala new file mode 100644 index 0000000..08e7e0f --- /dev/null +++ b/admin/test/com/lucidchart/piezo/admin/util/CronHelperTest.scala @@ -0,0 +1,95 @@ +package com.lucidchart.piezo.admin.util + +import com.lucidchart.piezo.admin.utils.CronHelper +import java.util.TimeZone +import org.quartz.CronExpression +import org.specs2.mutable.Specification + +class CronHelperTest extends Specification { + + val SECOND: Int = 1 + val MINUTE: Int = 60 * SECOND + val HOUR: Int = 60 * MINUTE + val EXTRA_HOUR: Int = HOUR // denotes the extra hour from daylight savings + val DAY: Int = 24 * HOUR + val WEEK: Int = 7 * DAY + val YEAR: Int = 365 * DAY + val LEAP_YEAR: Int = YEAR + DAY + val IMPOSSIBLE: Long = Long.MaxValue + + val UTC: Option[TimeZone] = Some(TimeZone.getTimeZone("UTC")) + val MDT: Option[TimeZone] = Some(TimeZone.getTimeZone("MST7MDT")) + + def maxInterval(str: String, timeZone: Option[TimeZone] = UTC): Long = { + CronHelper.getMaxInterval(new CronExpression(str), timeZone) + } + + "CronHelper" should { + "timezones should be configured properly" in { + MDT must beSome { timezone: TimeZone => timezone.observesDaylightTime() must beTrue } + UTC must beSome { timezone: TimeZone => timezone.observesDaylightTime() must beFalse } + } + + "validate basic cron expressions" in { + maxInterval("* * * * * ?") mustEqual SECOND // every second + maxInterval("0 * * * * ?") mustEqual MINUTE // second 0 of every minute + maxInterval("0 0 * * * ?") mustEqual HOUR // second 0 during minute 0 of every hour + maxInterval("0 0 0 * * ?") mustEqual DAY // second 0 during minute 0 during hour 0 of every day + maxInterval("* 0 * * * ?") mustEqual (HOUR - MINUTE + SECOND) // every second during minute 0 + maxInterval("* * 0 * * ?") mustEqual (DAY - HOUR + SECOND) + } + + "validate more basic cron expressions" in { + maxInterval("0/1 0-59 */1 * * ?") mustEqual SECOND // variations on 1 second + maxInterval("* * 0-23 * * ?", MDT) mustEqual SECOND + maxInterval("22 2/6 * * * ?") mustEqual 6 * MINUTE // 22nd second of every 6th minute after minute 2 + maxInterval("*/15 * * * * ?") mustEqual 15 * SECOND + maxInterval("30 10 */1 * * ?") mustEqual HOUR + maxInterval("15 * * * * ?") mustEqual MINUTE + maxInterval("3,2,1,0 45,44,16,15 6,5,4 * * ? *") mustEqual (21 * HOUR + 29 * MINUTE + 57 * SECOND) + maxInterval("50-0 30-40 14-12 * * ?") mustEqual (21 * HOUR + 49 * MINUTE + 10 * SECOND) + maxInterval("0 0 8-4 * * ?") mustEqual (DAY - 4 * HOUR) + maxInterval("0 0 0/6 * * ? *") mustEqual (6 * HOUR) + } + + "validate daylight savings expressions with simple methods" in { + maxInterval("0 0 1 * * ?", UTC) mustEqual DAY + maxInterval("0 0 1 * * ?", MDT) mustEqual DAY + EXTRA_HOUR + maxInterval("0 0 2,0 * * ?", MDT) mustEqual (DAY - 2 * HOUR) + EXTRA_HOUR + maxInterval("0 0 2,3 * * ?", MDT) mustEqual (DAY - 1 * HOUR) + EXTRA_HOUR + maxInterval("0 0 2,5 * * ?", MDT) mustEqual (DAY - 5 * HOUR) + ((5 - 1) * HOUR) + maxInterval("0 0 2,5,6,7,12 * * ?", MDT) mustEqual (DAY - 12 * HOUR) + ((5 - 1) * HOUR) + maxInterval("0 0 2,22,23 * * ?", MDT) mustEqual (DAY - 23 * HOUR) + ((22 - 1) * HOUR) + maxInterval("0 0 2 * * ?", UTC) mustEqual DAY + maxInterval("0 0 2 * * ?", MDT) mustEqual DAY + 23 * HOUR + } + + "validate daylight savings expressions with complex methods" in { + maxInterval("0 0 1 * 1-12 ?", UTC) mustEqual DAY + maxInterval("0 0 1 * 1-12 ?", MDT) mustEqual DAY + EXTRA_HOUR + maxInterval("0 0 2,0 * 1-12 ?", MDT) mustEqual (DAY - 2 * HOUR) + EXTRA_HOUR + maxInterval("0 0 2,3 * 1-12 ?", MDT) mustEqual (DAY - 1 * HOUR) + EXTRA_HOUR + maxInterval("0 0 2,5 * 1-12 ?", MDT) mustEqual (DAY - 5 * HOUR) + ((5 - 1) * HOUR) + maxInterval("0 0 2,5,6,7,12 * 1-12 ?", MDT) mustEqual (DAY - 12 * HOUR) + ((5 - 1) * HOUR) + maxInterval("0 0 2,22,23 * 1-12 ?", MDT) mustEqual (DAY - 23 * HOUR) + ((22 - 1) * HOUR) + maxInterval("0 0 2 * 1-12 ?", UTC) mustEqual DAY + maxInterval("0 0 2 * 1-12 ?", MDT) mustEqual DAY + 23 * HOUR + } + + "validate complex cron expressions" in { + maxInterval("0/15 * * 1-12 * ?") mustEqual 19 * DAY + 15 * SECOND // every 15 seconds on days 1-12 of the month + maxInterval("* * * * 1-11 ?") mustEqual 31 * DAY + SECOND // every second of every month except for december + maxInterval("* * * * * ? 1998") mustEqual IMPOSSIBLE // every second of 1998 + maxInterval("0 0 0 29 2 ? *") mustEqual 8 * YEAR + DAY // 8 years since we skip leap day roughly every 100 years + maxInterval("* * * 29 2 ? *") mustEqual 8 * YEAR + SECOND // every second on leap day + maxInterval("0 11 11 11 11 ?") mustEqual LEAP_YEAR // every november 11th at 11:11am + maxInterval("1 2 3 ? * 6", MDT) mustEqual WEEK + EXTRA_HOUR // every saturday + maxInterval("0 15 10 ? * 6#3", MDT) mustEqual 5 * WEEK + EXTRA_HOUR // third saturday of every month + maxInterval("0 15 10 ? * MON-FRI", MDT) mustEqual 3 * DAY + EXTRA_HOUR // every weekday + maxInterval("0 0 0/6 * 1,2,3,4,5,6,7,8,9,10,11,12 ? *", MDT) mustEqual DAY - (18 * HOUR) + EXTRA_HOUR + maxInterval("* * * 1-31 * ?", MDT) mustEqual SECOND + EXTRA_HOUR + maxInterval("* * * * 1-12 ?", MDT) mustEqual SECOND + EXTRA_HOUR + maxInterval("* * * ? * 1-7", MDT) mustEqual SECOND + EXTRA_HOUR + } + } +} From 6b9e151a6944c2b13a15600ba9fed8b14f6481dc Mon Sep 17 00:00:00 2001 From: Matt Spataro Date: Fri, 23 Jun 2023 01:03:53 -0600 Subject: [PATCH 2/2] simplify the algorithm --- .../admin/controllers/TriggerFormHelper.scala | 17 +- .../piezo/admin/utils/CronHelper.scala | 270 ++++++++---------- .../piezo/admin/util/CronHelperTest.scala | 63 +--- 3 files changed, 144 insertions(+), 206 deletions(-) diff --git a/admin/app/com/lucidchart/piezo/admin/controllers/TriggerFormHelper.scala b/admin/app/com/lucidchart/piezo/admin/controllers/TriggerFormHelper.scala index bafafe8..349bd60 100644 --- a/admin/app/com/lucidchart/piezo/admin/controllers/TriggerFormHelper.scala +++ b/admin/app/com/lucidchart/piezo/admin/controllers/TriggerFormHelper.scala @@ -9,7 +9,7 @@ import play.api.data.{Form, FormError} import play.api.data.Forms._ import play.api.data.format.Formats.parsing import play.api.data.format.Formatter -import play.api.data.validation.{Constraint, Invalid, Valid, ValidationError} +import play.api.data.validation.{Constraint, Constraints, Invalid, Valid, ValidationError} class TriggerFormHelper(scheduler: Scheduler) extends JobDataHelper { @@ -124,11 +124,6 @@ class TriggerFormHelper(scheduler: Scheduler) extends JobDataHelper { } } - def greaterThan(greaterThanValue: Int): Constraint[Int] = Constraint[Int]("constraints.greaterThan") { value => - if (value > greaterThanValue) Valid - else Invalid(ValidationError(s"Value must be greater than $greaterThanValue")) - } - def buildTriggerForm = Form[(Trigger, TriggerMonitoringPriority, Int)]( mapping( "triggerType" -> nonEmptyText(), @@ -150,19 +145,13 @@ class TriggerFormHelper(scheduler: Scheduler) extends JobDataHelper { ), "job-data-map" -> jobDataMap, "triggerMonitoringPriority" -> nonEmptyText(), - "triggerMaxErrorTime" -> of(MaxSecondsBetweenSuccessesFormatter).verifying(greaterThan(0)), + "triggerMaxErrorTime" -> of(MaxSecondsBetweenSuccessesFormatter).verifying(Constraints.min(0)), )(triggerFormApply)(triggerFormUnapply) .verifying( "Job does not exist", fields => { scheduler.checkExists(fields._1.getJobKey) }, - ) - .verifying( - "Max time between successes must be greater than 0", - fields => { - fields._3 > 0 - }, ), ) } @@ -174,7 +163,7 @@ object MaxSecondsBetweenSuccessesFormatter extends Formatter[Int] { maxSecondsBetweenSuccesses <- parsing(_.toInt, "Numeric value expected", Nil)(key, data) maxIntervalTime <- { if (data.contains("cron.cronExpression")) { - parsing(expr => CronHelper.getMaxInterval(new CronExpression(expr)), "try again.", Nil)( + parsing(expr => CronHelper.getMaxInterval(expr), "try again.", Nil)( "cron.cronExpression", data, ) diff --git a/admin/app/com/lucidchart/piezo/admin/utils/CronHelper.scala b/admin/app/com/lucidchart/piezo/admin/utils/CronHelper.scala index 01a0627..e1a0913 100644 --- a/admin/app/com/lucidchart/piezo/admin/utils/CronHelper.scala +++ b/admin/app/com/lucidchart/piezo/admin/utils/CronHelper.scala @@ -1,24 +1,48 @@ package com.lucidchart.piezo.admin.utils +import java.time.temporal.{ChronoUnit, TemporalUnit} +import java.time.{Instant, LocalDate, Month, ZoneOffset} import java.util.{Date, TimeZone} -import org.quartz.{CronExpression, CronScheduleBuilder, CronTrigger, TriggerBuilder} +import org.quartz.CronExpression import play.api.Logging +import scala.annotation.tailrec import scala.util.control.NonFatal object CronHelper extends Logging { + val IMPOSSIBLE_MAX_INTERVAL: Long = Long.MaxValue val DEFAULT_MAX_INTERVAL = 0 - val NUM_COMPLEX_SAMPLES = 8000 + val NON_EXISTENT: Int = -1 /** - * Determines the max interval for a cron expression. The operation fails silently because it's not crucial. + * Approximates the largest interval between two trigger events for a given cron expression. This is a difficult + * problem to solve perfectly, so this represents a "best effort approach" - the goal is to handle the most + * expressions with the least amount of complexity. + * + * Known limitations: + * 1. Daylight savings + * 1. Complex year subexpressions * @param cronExpression */ - def getMaxInterval(cronExpression: CronExpression): Long = getMaxInterval(cronExpression, None) - def getMaxInterval(cronExpression: CronExpression, timeZone: Option[TimeZone]): Long = { + def getMaxInterval(cronExpression: String): Long = { try { - val subexpressions = cronExpression.getCronExpression.split("\\s") - val isComplex = !subexpressions.drop(3).forall(expr => expr == "*" || expr == "?") - if (isComplex) getComplexMaxInterval(subexpressions, timeZone) else getSimpleMaxInterval(subexpressions, timeZone) + val (secondsMinutesHourStrings, dayStrings) = cronExpression.split("\\s+").splitAt(3) + val subexpressions = getSubexpressions(secondsMinutesHourStrings :+ dayStrings.mkString(" ")).reverse + + // find the largest subexpression that is not continuously triggering (*) + val outermostIndex = subexpressions.indexWhere(!_.isContinuouslyTriggering) + if (outermostIndex == NON_EXISTENT) 1 + else { + // get the max interval for this expression + val outermost = subexpressions(outermostIndex) + if (outermost.maxInterval == IMPOSSIBLE_MAX_INTERVAL) IMPOSSIBLE_MAX_INTERVAL + else { + // subtract the inner intervals of the smaller, nested subexpressions + val nested = subexpressions.slice(outermostIndex + 1, subexpressions.size) + val innerIntervalsOfNested = nested.collect { case expr: BoundSubexpression => expr.innerInterval }.sum + outermost.maxInterval - innerIntervalsOfNested + } + } + } catch { case NonFatal(e) => logger.error("Failed to validate cron expression", e) @@ -26,160 +50,120 @@ object CronHelper extends Logging { } } - /** - * Determines the max interval by deduction. Is only used when seconds, minutes, and hours are specified. If days, - * months, or years are specified, use getComplexMaxInterval instead. - */ - private def getSimpleMaxInterval(subexpressions: Array[String], timeZone: Option[TimeZone]): Long = { - val cronContainers = getCronContainers(subexpressions.take(3), timeZone) // seconds, minutes, hours - val outermostContainer = cronContainers.findLast(!_.areAllUnitsMarked) - outermostContainer.fold(1L) { outermost => - val innerContainers = cronContainers.takeWhile(_.unitType != outermost.unitType) - val innerIntervalSeconds = innerContainers.map(_.wrapIntervalInSeconds).sum - outermost.maxIntervalInSeconds + innerIntervalSeconds - } + private def getSubexpressions(parts: Array[String]): IndexedSeq[Subexpression] = { + parts + .zip(List(Seconds, Minutes, Hours, Days)) + .map { case (str, cronType) => cronType(str) } + .toIndexedSeq } +} - /** - * Estimates the max interval using a combination of deduction (for the seconds and minutes), and sampling (for - * everything else). We remove the seconds and minutes from the cron expression that is used for sampling so we can - * effectively decrease the number of samples needed for a good estimate. - */ - private def getComplexMaxInterval(subexpressions: Array[String], timeZone: Option[TimeZone]): Long = { - val (secondsAndMinutes, everythingElse) = subexpressions.splitAt(2) - - // set up the dummy trigger - val simplifiedComplexCron = everythingElse.mkString(s"0 0 ", " ", "") - val selectedTimeZone = timeZone.getOrElse(TimeZone.getDefault) - val cronSchedule = CronScheduleBuilder.cronSchedule(simplifiedComplexCron).inTimeZone(selectedTimeZone) - val dummyTrigger: CronTrigger = TriggerBuilder.newTrigger().withSchedule(cronSchedule).build() - val initialFireTime = Option(dummyTrigger.getFireTimeAfter(new Date())) - - // get the interval - initialFireTime.fold(Long.MaxValue) { initialFireTime => - logger.debug(s"sample cron expression: $simplifiedComplexCron") - val sampledMaxIntervalInSeconds = getSampledMaxInterval(initialFireTime, NUM_COMPLEX_SAMPLES, dummyTrigger) - val innerMaxIntervalSeconds = getCronContainers(secondsAndMinutes, timeZone).map(_.wrapIntervalInSeconds).sum - // subtract an hour to avoid double counting innerMaxIntervalSeconds - sampledMaxIntervalInSeconds - HourUnitType.secondsPerUnit + innerMaxIntervalSeconds - } - } +case class Seconds(str: String) extends BoundSubexpression(str, x => s"$x * * ? * *", ChronoUnit.SECONDS, 60) +case class Minutes(str: String) extends BoundSubexpression(str, x => s"0 $x * ? * *", ChronoUnit.MINUTES, 60) +case class Hours(str: String) extends BoundSubexpression(str, x => s"0 0 $x ? * *", ChronoUnit.HOURS, 24) +case class Days(str: String) extends UnboundSubexpression(str, x => s"0 0 0 $x", 400) - /** - * Estimates the max interval for a given cron expression. The more samples, the better the estimate. - */ - private def getSampledMaxInterval(prev: Date, numSamples: Long, trigger: CronTrigger, maxInterval: Long = 0): Long = { - Option(trigger.getFireTimeAfter(prev)) match { - case Some(next) if numSamples > 0 => - val intervalInSeconds = next.toInstant.getEpochSecond - prev.toInstant.getEpochSecond - if (intervalInSeconds > maxInterval) { - val sampleId = NUM_COMPLEX_SAMPLES - numSamples - logger.debug(s"Seconds:$intervalInSeconds Sample:$sampleId Interval:$prev -> $next") - } - getSampledMaxInterval(next, numSamples - 1, trigger, Math.max(intervalInSeconds, maxInterval)) - case _ => maxInterval - } - } +abstract class Subexpression(str: String, getSimplifiedCron: String => String) { + def maxInterval: Long + def isContinuouslyTriggering: Boolean - private def getCronContainers(parts: Array[String], timeZone: Option[TimeZone]): List[CronContainer] = { - parts - .zip(List(SecondUnitType, MinuteUnitType, HourUnitType)) - .map { case (str, cronType) => CronContainer(str, cronType, timeZone) } - .toList + protected def startDate: Date + final protected lazy val cron: CronExpression = { + val newCron = new CronExpression(getSimplifiedCron(str)) + newCron.setTimeZone(TimeZone.getTimeZone("UTC")) // use a timezone without daylight savings + newCron } - } -sealed abstract class UnitType(val secondsPerUnit: Long, val numUnitsInContainer: Long) -case object SecondUnitType extends UnitType(1, 60) -case object MinuteUnitType extends UnitType(60, 60) -case object HourUnitType extends UnitType(3600, 24) - /** - * A cron container is composed of "units". The "unitType" determines how many units are in the container, and how many - * seconds are in each unit. "Marked units" describe when the trigger is set to fire. "Intervals" describe the number of - * units between (but not including) marked units. + * Represents a subexpression in which the range over which the triggers occur is bound or fixed. For example, seconds + * always occur within a minute, minutes always occur within an hour, and hours always occur within a day. Because the + * range is fixed, we can determine all possibilities by sampling over the entire range. */ -case class CronContainer(str: String, unitType: UnitType, markedUnits: List[Long], timeZone: TimeZone) { - lazy val areAllUnitsMarked: Boolean = markedUnits.size == unitType.numUnitsInContainer - - // the "wrap interval" is the interval that wraps around both ends of the container - private lazy val wrapInterval = (unitType.numUnitsInContainer - markedUnits.last) + markedUnits.head - lazy val wrapIntervalInSeconds: Long = unitsToSeconds(wrapInterval) - - // the max interval is the longest interval between any two marked units in the container, including the wrap interval - lazy val maxIntervalInSeconds: Long = { - val otherIntervals = markedUnits.zipWithIndex - .take(markedUnits.size - 1) - .map { case (value, index) => markedUnits(index + 1) - value } - val units = (otherIntervals :+ wrapInterval).max - val unitsWithDaylightSavings = units + (if (unitType == HourUnitType) getDaylightSavings(units, markedUnits) else 0) - unitsToSeconds(unitsWithDaylightSavings) - } +abstract class BoundSubexpression( + str: String, + getSimplifiedCron: String => String, + temporalUnit: TemporalUnit, + val numUnitsInContainer: Long, +) extends Subexpression(str, getSimplifiedCron) { + + final override protected val startDate = new Date(BoundSubexpression.startInstant.toEpochMilli) + final protected val endDate = Date.from( + BoundSubexpression.startInstant.plus(numUnitsInContainer, temporalUnit), + ) + final override lazy val maxInterval: Long = getMaxInterval(cron, startDate, endDate, 0) + final override lazy val isContinuouslyTriggering: Boolean = maxInterval == temporalUnit.getDuration.getSeconds /** - * We multiply the number of units by the number of seconds in a single unit. We also subtract 1 unit to avoid double - * counting the seconds in sub-intervals that are within a single unit. For example: - * - We subtract 1 hour when counting hours because the minutes make up the last hour - * - We subtract 1 minute when counting minutes because the seconds make up the last minute - * - We don't subtract 1 second when counting seconds because there are no smaller units + * The interval between the first and last trigger within the range, or "everything but the ends". Should encompass + * every trigger produced by the subexpression. */ - private def unitsToSeconds(units: Long): Long = { - if (unitType == SecondUnitType) units else (units - 1) * unitType.secondsPerUnit + final lazy val innerInterval: Long = getInnerInterval(cron, startDate, endDate) + + @tailrec + private def getMaxInterval(expr: CronExpression, prev: Date, end: Date, maxInterval: Long): Long = { + Option(expr.getTimeAfter(prev)) match { + case Some(curr) if !prev.after(end) => // iterate once past the "end" in order to wrap around + val currentInterval = (curr.getTime - prev.getTime) / 1000 + val newMax = Math.max(currentInterval, maxInterval) + getMaxInterval(expr, curr, end, newMax) + case _ => maxInterval + } } - /** - * Returns the number of seconds to add to the interval if daylight savings is being observed. - */ - private def getDaylightSavings(currentNumUnits: Long, hourInstants: List[Long]): Long = { - if (timeZone.observesDaylightTime()) { - val extraSpringForwardHours = if (hourInstants.contains(2)) { - val allBut2am = hourInstants.filter(_ != 2) // when springing forward, 2am is skipped for a day - if (allBut2am.isEmpty) { - 23 // if we only trigger at 2am and 2am is skipped, we won't trigger for another 23 hours - } else { - // calculate the unique wrap interval when we spring forward - // (number of hours remaining in the day, plus the number of hours until the first trigger in the next day) - (24 - allBut2am.last) + (allBut2am.head - 1) - currentNumUnits - } - } else -1 - val extraFallbackHours = 1 // we always gain an extra hour when falling back (1am is delayed until 2am) - Math.max(extraFallbackHours, extraSpringForwardHours) - } else 0 + private def getInnerInterval(expr: CronExpression, prev: Date, end: Date): Long = { + Option(expr.getTimeAfter(prev)).fold(Long.MaxValue) { firstTriggerDate => + val firstTriggerTime = firstTriggerDate.getTime / 1000 + val lastTriggerTime = getLastTriggerTime(expr, firstTriggerDate, end) + lastTriggerTime - firstTriggerTime + } } + @tailrec + private def getLastTriggerTime(expr: CronExpression, prev: Date, end: Date): Long = { + Option(expr.getTimeAfter(prev)) match { // stop iterating before going past the "end" + case Some(curr) if !curr.after(end) => getLastTriggerTime(expr, curr, end) + case _ => prev.getTime / 1000 + } + } } -object CronContainer { - def apply(str: String, cronType: UnitType, timeZone: Option[TimeZone]): CronContainer = { - val markedUnits = CronContainer.getMarkedUnits(str, cronType.numUnitsInContainer) - CronContainer(str, cronType, markedUnits, timeZone.getOrElse(TimeZone.getDefault)) - } +object BoundSubexpression { + final protected val startInstant: Instant = LocalDate + .of(2010, Month.SEPTEMBER, 3) + .atStartOfDay + .toInstant(ZoneOffset.UTC) + .minus(1, ChronoUnit.SECONDS) +} - def getMarkedUnits(str: String, unitsInContainer: Long): List[Long] = { - val slash = """(\d{1,2}|\*)/(\d{1,2})""".r - val dash = """(\d{1,2})-(\d{1,2})""".r - str match { - case "*" => (for (i <- 0L until unitsInContainer) yield i).toList - case slash(start, interval) => { - val startInt = if (start == "*") 0 else start.toLong - getMarkedUnitsForSlash(startInt, interval.toLong, unitsInContainer, List(startInt)) - } - case dash(first, second) => - val smallest = Math.min(first.toLong, second.toLong) - val largest = Math.max(first.toLong, second.toLong) - (for (i <- smallest to largest) yield i).toList - case _ => str.split(",").map(_.toLong).toList.sorted +/** + * Represents a subexpression that is unbound, meaning that the range over which triggers occur is unknown, or is + * variable. For example, days can occur within a week, month, or year, and each of these ranges can vary in size. + * Because we can't determine the range over which days are triggered, we estimate the max interval by sampling a + * certain number of times. The larger the number of samples, the more accurate the estimate. + */ +abstract class UnboundSubexpression( + str: String, + getSimplifiedCron: String => String, + val maxNumSamples: Long, +) extends Subexpression(str, getSimplifiedCron) + with Logging { + + final override protected val startDate = new Date + final override lazy val maxInterval: Long = getSampledMaxInterval(startDate, maxNumSamples, cron) + final override lazy val isContinuouslyTriggering: Boolean = str.split(" ").forall(expr => expr == "*" || expr == "?") + + @tailrec + private def getSampledMaxInterval(prev: Date, numSamples: Long, expr: CronExpression, maxInterval: Long = 0): Long = { + Option(expr.getTimeAfter(prev)) match { + case Some(next) if numSamples > 0 => + val intervalInSeconds = (next.getTime - prev.getTime) / 1000 + if (intervalInSeconds > maxInterval) { + val sampleId = maxNumSamples - numSamples + logger.debug(s"Seconds:$intervalInSeconds Sample:$sampleId Interval:$prev -> $next") + } + getSampledMaxInterval(next, numSamples - 1, expr, Math.max(intervalInSeconds, maxInterval)) + case _ => if (prev.equals(startDate)) CronHelper.IMPOSSIBLE_MAX_INTERVAL else maxInterval } } - - private def getMarkedUnitsForSlash( - start: Long, - interval: Long, - unitsInContainer: Long, - result: List[Long] = Nil, - ): List[Long] = { - if (start + interval >= unitsInContainer) result - else getMarkedUnitsForSlash(start + interval, interval, unitsInContainer, result :+ (start + interval)) - } } diff --git a/admin/test/com/lucidchart/piezo/admin/util/CronHelperTest.scala b/admin/test/com/lucidchart/piezo/admin/util/CronHelperTest.scala index 08e7e0f..9e0e549 100644 --- a/admin/test/com/lucidchart/piezo/admin/util/CronHelperTest.scala +++ b/admin/test/com/lucidchart/piezo/admin/util/CronHelperTest.scala @@ -1,8 +1,6 @@ package com.lucidchart.piezo.admin.util import com.lucidchart.piezo.admin.utils.CronHelper -import java.util.TimeZone -import org.quartz.CronExpression import org.specs2.mutable.Specification class CronHelperTest extends Specification { @@ -10,26 +8,15 @@ class CronHelperTest extends Specification { val SECOND: Int = 1 val MINUTE: Int = 60 * SECOND val HOUR: Int = 60 * MINUTE - val EXTRA_HOUR: Int = HOUR // denotes the extra hour from daylight savings val DAY: Int = 24 * HOUR val WEEK: Int = 7 * DAY val YEAR: Int = 365 * DAY val LEAP_YEAR: Int = YEAR + DAY val IMPOSSIBLE: Long = Long.MaxValue - val UTC: Option[TimeZone] = Some(TimeZone.getTimeZone("UTC")) - val MDT: Option[TimeZone] = Some(TimeZone.getTimeZone("MST7MDT")) - - def maxInterval(str: String, timeZone: Option[TimeZone] = UTC): Long = { - CronHelper.getMaxInterval(new CronExpression(str), timeZone) - } + def maxInterval(str: String): Long = CronHelper.getMaxInterval(str) "CronHelper" should { - "timezones should be configured properly" in { - MDT must beSome { timezone: TimeZone => timezone.observesDaylightTime() must beTrue } - UTC must beSome { timezone: TimeZone => timezone.observesDaylightTime() must beFalse } - } - "validate basic cron expressions" in { maxInterval("* * * * * ?") mustEqual SECOND // every second maxInterval("0 * * * * ?") mustEqual MINUTE // second 0 of every minute @@ -41,39 +28,17 @@ class CronHelperTest extends Specification { "validate more basic cron expressions" in { maxInterval("0/1 0-59 */1 * * ?") mustEqual SECOND // variations on 1 second - maxInterval("* * 0-23 * * ?", MDT) mustEqual SECOND + maxInterval("* * 0-23 * * ?") mustEqual SECOND maxInterval("22 2/6 * * * ?") mustEqual 6 * MINUTE // 22nd second of every 6th minute after minute 2 maxInterval("*/15 * * * * ?") mustEqual 15 * SECOND maxInterval("30 10 */1 * * ?") mustEqual HOUR maxInterval("15 * * * * ?") mustEqual MINUTE maxInterval("3,2,1,0 45,44,16,15 6,5,4 * * ? *") mustEqual (21 * HOUR + 29 * MINUTE + 57 * SECOND) - maxInterval("50-0 30-40 14-12 * * ?") mustEqual (21 * HOUR + 49 * MINUTE + 10 * SECOND) - maxInterval("0 0 8-4 * * ?") mustEqual (DAY - 4 * HOUR) - maxInterval("0 0 0/6 * * ? *") mustEqual (6 * HOUR) - } - - "validate daylight savings expressions with simple methods" in { - maxInterval("0 0 1 * * ?", UTC) mustEqual DAY - maxInterval("0 0 1 * * ?", MDT) mustEqual DAY + EXTRA_HOUR - maxInterval("0 0 2,0 * * ?", MDT) mustEqual (DAY - 2 * HOUR) + EXTRA_HOUR - maxInterval("0 0 2,3 * * ?", MDT) mustEqual (DAY - 1 * HOUR) + EXTRA_HOUR - maxInterval("0 0 2,5 * * ?", MDT) mustEqual (DAY - 5 * HOUR) + ((5 - 1) * HOUR) - maxInterval("0 0 2,5,6,7,12 * * ?", MDT) mustEqual (DAY - 12 * HOUR) + ((5 - 1) * HOUR) - maxInterval("0 0 2,22,23 * * ?", MDT) mustEqual (DAY - 23 * HOUR) + ((22 - 1) * HOUR) - maxInterval("0 0 2 * * ?", UTC) mustEqual DAY - maxInterval("0 0 2 * * ?", MDT) mustEqual DAY + 23 * HOUR - } - - "validate daylight savings expressions with complex methods" in { - maxInterval("0 0 1 * 1-12 ?", UTC) mustEqual DAY - maxInterval("0 0 1 * 1-12 ?", MDT) mustEqual DAY + EXTRA_HOUR - maxInterval("0 0 2,0 * 1-12 ?", MDT) mustEqual (DAY - 2 * HOUR) + EXTRA_HOUR - maxInterval("0 0 2,3 * 1-12 ?", MDT) mustEqual (DAY - 1 * HOUR) + EXTRA_HOUR - maxInterval("0 0 2,5 * 1-12 ?", MDT) mustEqual (DAY - 5 * HOUR) + ((5 - 1) * HOUR) - maxInterval("0 0 2,5,6,7,12 * 1-12 ?", MDT) mustEqual (DAY - 12 * HOUR) + ((5 - 1) * HOUR) - maxInterval("0 0 2,22,23 * 1-12 ?", MDT) mustEqual (DAY - 23 * HOUR) + ((22 - 1) * HOUR) - maxInterval("0 0 2 * 1-12 ?", UTC) mustEqual DAY - maxInterval("0 0 2 * 1-12 ?", MDT) mustEqual DAY + 23 * HOUR + maxInterval("50-0 30-40 14-12 * * ?") mustEqual (1 * HOUR + 49 * MINUTE + 1 * SECOND) + maxInterval("0 0 8-4 * * ?") mustEqual 4 * HOUR + maxInterval("0 0 0/6 * * ? *") mustEqual 6 * HOUR + maxInterval("0 10,20,30 * * ? *") mustEqual 40 * MINUTE + maxInterval("0-10/2 0-5,20-25 0,5-11/2,20-23 * ? *") mustEqual 8 * HOUR + 34 * MINUTE + 50 * SECOND } "validate complex cron expressions" in { @@ -83,13 +48,13 @@ class CronHelperTest extends Specification { maxInterval("0 0 0 29 2 ? *") mustEqual 8 * YEAR + DAY // 8 years since we skip leap day roughly every 100 years maxInterval("* * * 29 2 ? *") mustEqual 8 * YEAR + SECOND // every second on leap day maxInterval("0 11 11 11 11 ?") mustEqual LEAP_YEAR // every november 11th at 11:11am - maxInterval("1 2 3 ? * 6", MDT) mustEqual WEEK + EXTRA_HOUR // every saturday - maxInterval("0 15 10 ? * 6#3", MDT) mustEqual 5 * WEEK + EXTRA_HOUR // third saturday of every month - maxInterval("0 15 10 ? * MON-FRI", MDT) mustEqual 3 * DAY + EXTRA_HOUR // every weekday - maxInterval("0 0 0/6 * 1,2,3,4,5,6,7,8,9,10,11,12 ? *", MDT) mustEqual DAY - (18 * HOUR) + EXTRA_HOUR - maxInterval("* * * 1-31 * ?", MDT) mustEqual SECOND + EXTRA_HOUR - maxInterval("* * * * 1-12 ?", MDT) mustEqual SECOND + EXTRA_HOUR - maxInterval("* * * ? * 1-7", MDT) mustEqual SECOND + EXTRA_HOUR + maxInterval("1 2 3 ? * 6") mustEqual WEEK // every saturday + maxInterval("0 15 10 ? * 6#3") mustEqual 5 * WEEK // third saturday of every month + maxInterval("0 15 10 ? * MON-FRI") mustEqual 3 * DAY // every weekday + maxInterval("0 0 0/6 * 1,2,3,4,5,6,7,8,9,10,11,12 ? *") mustEqual DAY - (18 * HOUR) + maxInterval("* * * 1-31 * ?") mustEqual SECOND + maxInterval("* * * * 1-12 ?") mustEqual SECOND + maxInterval("* * * ? * 1-7") mustEqual SECOND } } }