Skip to content

Commit

Permalink
merge: #9674
Browse files Browse the repository at this point in the history
9674: feat(engine): support cron expression for timer cycle. r=korthout a=lzgabel

## Description

<!-- Please explain the changes you made here. -->
As a user, I want to be able to set the `cron expression` for `timer cycle`.

Parse the given crontab expression  string. The string has six single space-separated time and date fields:

   <pre>
   &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472; second (0-59)
   &#9474; &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472; minute (0 - 59)
   &#9474; &#9474; &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472; hour (0 - 23)
   &#9474; &#9474; &#9474; &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472; day of the month (1 - 31)
   &#9474; &#9474; &#9474; &#9474; &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472; month (1 - 12) (or JAN-DEC)
   &#9474; &#9474; &#9474; &#9474; &#9474; &#9484;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472; day of the week (0 - 7)
   &#9474; &#9474; &#9474; &#9474; &#9474; &#9474;          (0 or 7 is Sunday, or MON-SUN)
   &#9474; &#9474; &#9474; &#9474; &#9474; &#9474;
   &#42; &#42; &#42; &#42; &#42; &#42;
   </pre>
     
The following rules apply:
* A field may be an asterisk (*), which always stands for "first-last". For the "day of the month" or "day of the week" fields, a question mark (?) may be used instead of an asterisk.
* Ranges of numbers are expressed by two numbers separated with a hyphen (-). The specified range is inclusive.
* Following a range (or *) with /n specifies the interval of the number's value through the range.
* English names can also be used for the "month" and "day of week" fields. Use the first three letters of the particular day or month (case does not matter).
* The "day of month" and "day of week" fields can contain a L-character, which stands for "last", and has a different meaning in each field:
  * In the "day of month" field, L stands for "the last day of the month". If followed by an negative offset (i.e. L-n), it means "nth-to-last day of the month". If followed by W (i.e. LW), it means "the last weekday of the month".
  * In the "day of week" field, L stands for "the last day of the week". If prefixed by a number or three-letter name (i.e. dL or DDDL), it means "the last day of week d (or DDD) in the month".
* The "day of month" field can be nW, which stands for "the nearest weekday to day of the month n". If n falls on * Saturday, this yields the Friday before it. If n falls on Sunday, this yields the Monday after, which also happens if n is 1 and falls on a Saturday (i.e. 1W stands for "the first weekday of the month").
* The "day of week" field can be d#n (or DDD#n), which stands for "the n-th day of week d (or DDD) in the month".

#### Example expressions:

* "0 0 * * * *" = the top of every hour of every day.
* "*/10 * * * * *" = every ten seconds.
* "0 0 8-10 * * *" = 8, 9 and 10 o'clock of every day.
* "0 0 6,19 * * *" = 6:00 AM and 7:00 PM every day.
* "0 0/30 8-10 * * *" = 8:00, 8:30, 9:00, 9:30, 10:00 and 10:30 every day.
* "0 0 9-17 * * MON-FRI" = on the hour nine-to-five weekdays
* "0 0 0 25 12 ?" = every Christmas Day at midnight
* "0 0 0 L * *" = last day of the month at midnight
* "0 0 0 L-3 * *" = third-to-last day of the month at midnight
* "0 0 0 1W * *" = first weekday of the month at midnight
* "0 0 0 LW * *" = last weekday of the month at midnight
* "0 0 0 * * 5L" = last Friday of the month at midnight
* "0 0 0 * * THUL" = last Thursday of the month at midnight
* "0 0 0 ? * 5#2" = the second Friday in the month at midnight
* "0 0 0 ? * MON#1" = the first Monday in the month at midnight
 of the month at midnight
* "0 0 0 * * 5L" = last Friday of the month at midnight
* "0 0 0 * * THUL" = last Thursday of the month at midnight
* "0 0 0 ? * 5#2" = the second Friday in the month at midnight
* "0 0 0 ? * MON#1" = the first Monday in the month at midnight
## Related issues

<!-- Which issues are closed by this PR or are related -->

closes #9673 



Co-authored-by: lzgabel <lz19960321lz@gmail.com>
  • Loading branch information
zeebe-bors-camunda[bot] and lzgabel committed Aug 3, 2022
2 parents d672c7e + 04114e4 commit 77114d6
Show file tree
Hide file tree
Showing 9 changed files with 629 additions and 19 deletions.
5 changes: 5 additions & 0 deletions engine/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@
<artifactId>commons-lang3</artifactId>
</dependency>

<dependency>
<groupId>com.cronutils</groupId>
<artifactId>cron-utils</artifactId>
</dependency>

<!-- TEST DEPENDENCIES -->
<dependency>
<groupId>io.camunda</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import io.camunda.zeebe.engine.processing.deployment.model.element.ExecutableProcess;
import io.camunda.zeebe.engine.processing.deployment.model.transformation.ModelElementTransformer;
import io.camunda.zeebe.engine.processing.deployment.model.transformation.TransformContext;
import io.camunda.zeebe.engine.processing.timer.CronTimer;
import io.camunda.zeebe.model.bpmn.instance.CatchEvent;
import io.camunda.zeebe.model.bpmn.instance.ErrorEventDefinition;
import io.camunda.zeebe.model.bpmn.instance.EventDefinition;
Expand Down Expand Up @@ -98,7 +99,13 @@ private void transformTimerEventDefinition(
try {
return expressionProcessor
.evaluateStringExpression(expression, scopeKey)
.map(RepeatingInterval::parse);
.map(
text -> {
if (text.startsWith("R")) {
return RepeatingInterval.parse(text);
}
return CronTimer.parse(text);
});
} catch (final DateTimeParseException e) {
// todo(#4323): replace this caught exception with Either
return Either.left(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import io.camunda.zeebe.el.ExpressionLanguage;
import io.camunda.zeebe.engine.processing.common.ExpressionProcessor;
import io.camunda.zeebe.engine.processing.common.Failure;
import io.camunda.zeebe.engine.processing.timer.CronTimer;
import io.camunda.zeebe.model.bpmn.instance.CatchEvent;
import io.camunda.zeebe.model.bpmn.instance.Process;
import io.camunda.zeebe.model.bpmn.instance.StartEvent;
Expand Down Expand Up @@ -89,7 +90,13 @@ private void validation(
try {
return expressionProcessor
.evaluateStringExpression(expression, NO_VARIABLE_SCOPE)
.map(RepeatingInterval::parse)
.map(
text -> {
if (text.startsWith("R")) {
return RepeatingInterval.parse(text);
}
return CronTimer.parse(text);
})
.mapLeft(wrapFailure("cycle"));
} catch (final DateTimeParseException e) {
final var failureDetails = new Failure(e.getMessage());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH under
* one or more contributor license agreements. See the NOTICE file distributed
* with this work for additional information regarding copyright ownership.
* Licensed under the Zeebe Community License 1.1. You may not use this file
* except in compliance with the Zeebe Community License 1.1.
*/
package io.camunda.zeebe.engine.processing.timer;

import com.cronutils.model.Cron;
import com.cronutils.model.CronType;
import com.cronutils.model.definition.CronDefinitionBuilder;
import com.cronutils.model.time.ExecutionTime;
import com.cronutils.parser.CronParser;
import io.camunda.zeebe.model.bpmn.util.time.Interval;
import io.camunda.zeebe.model.bpmn.util.time.Timer;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeParseException;
import java.util.Objects;

public class CronTimer implements Timer {

private final Cron cron;

private int repetitions;

public CronTimer(final Cron cron) {
this.cron = cron;
}

@Override
public Interval getInterval() {
return null;
}

@Override
public int getRepetitions() {
return repetitions;
}

@Override
public long getDueDate(final long fromEpochMilli) {
// set default value to -1
repetitions = -1;

final var next =
ExecutionTime.forCron(cron)
.nextExecution(
ZonedDateTime.ofInstant(
Instant.ofEpochMilli(fromEpochMilli), ZoneId.systemDefault()))
.map(ZonedDateTime::toInstant)
.map(Instant::toEpochMilli);

// set `repetitions` to 0 when the next execution time does not exist
if (next.isEmpty()) {
repetitions = 0;
}

return next.orElse(fromEpochMilli);
}

public static CronTimer parse(final String text) {
try {
final var cron =
new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.SPRING53))
.parse(text);
return new CronTimer(cron);
} catch (final IllegalArgumentException | NullPointerException ex) {
throw new DateTimeParseException(ex.getMessage(), Objects.requireNonNullElse(text, ""), 0);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -157,25 +157,30 @@ private void rescheduleTimer(
// todo(#4208): raise incident instead of throwing an exception
}

int repetitions = record.getRepetitions();
if (repetitions != RepeatingInterval.INFINITE) {
repetitions--;
}

// Use the timer's last due date instead of the current time to avoid a time shift.
final Interval refreshedInterval =
timer
.map(Timer::getInterval)
.map(interval -> interval.withStart(Instant.ofEpochMilli(record.getDueDate())))
.get();
final Timer repeatingInterval = new RepeatingInterval(repetitions, refreshedInterval);
final Timer refreshedTimer = refreshTimer(timer.get(), record);
catchEventBehavior.subscribeToTimerEvent(
record.getElementInstanceKey(),
record.getProcessInstanceKey(),
record.getProcessDefinitionKey(),
event.getId(),
repeatingInterval,
refreshedTimer,
writer,
sideEffects::accept);
}

private Timer refreshTimer(final Timer timer, final TimerRecord record) {
if (timer instanceof CronTimer) {
return timer;
}

int repetitions = record.getRepetitions();
if (repetitions != RepeatingInterval.INFINITE) {
repetitions--;
}

// Use the timer's last due date instead of the current time to avoid a time shift.
final Interval refreshedInterval =
timer.getInterval().withStart(Instant.ofEpochMilli(record.getDueDate()));
return new RepeatingInterval(repetitions, refreshedInterval);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,7 @@ void invalidCycleFormat(
final var process = timerEventBuilder.timerWithCycle("foo").done();

ProcessValidationUtil.validateProcess(
process,
expect(
timerEventElementId,
"Invalid timer cycle expression (Repetition spec must start with R)"));
process, expect(timerEventElementId, "Invalid timer cycle expression"));
}

@ParameterizedTest(name = "[{index}] {0}")
Expand Down

0 comments on commit 77114d6

Please sign in to comment.