Skip to content

Commit

Permalink
feat(core): Schedule with seconds
Browse files Browse the repository at this point in the history
Fixes #3941
  • Loading branch information
loicmathieu committed Jun 20, 2024
1 parent c97d6a9 commit fb2732e
Show file tree
Hide file tree
Showing 8 changed files with 190 additions and 148 deletions.
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
package io.kestra.core.validations;

import io.kestra.core.validations.validator.CronExpressionValidator;
import io.kestra.core.validations.validator.ScheduleValidator;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = CronExpressionValidator.class)
public @interface CronExpression {
String message() default "invalid cron expression ({validatedValue})";
@Constraint(validatedBy = ScheduleValidator.class)
public @interface ScheduleValidation {
String message() default "invalid cron expression ({validatedValue.cron})";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package io.kestra.core.validations.validator;

import com.cronutils.model.Cron;
import io.kestra.core.validations.ScheduleValidation;
import io.kestra.plugin.core.trigger.Schedule;
import io.micronaut.core.annotation.AnnotationValue;
import io.micronaut.core.annotation.Introspected;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.validation.validator.constraints.ConstraintValidator;
import io.micronaut.validation.validator.constraints.ConstraintValidatorContext;
import jakarta.inject.Singleton;

@Singleton
@Introspected
public class ScheduleValidator implements ConstraintValidator<ScheduleValidation, Schedule> {
@Override
public boolean isValid(
@Nullable Schedule value,
@NonNull AnnotationValue<ScheduleValidation> annotationMetadata,
@NonNull ConstraintValidatorContext context) {
if (value == null) {
return true;
}

if (value.getCron() != null) { // if null, the standard @NotNull will do its job
try {
Cron parsed = value.parseCron();
parsed.validate();
} catch (IllegalArgumentException e) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate( "invalid cron expression '" + value.getCron() + "': " + e.getMessage())
.addConstraintViolation();
return false;
}
}

return true;
}
}
34 changes: 24 additions & 10 deletions core/src/main/java/io/kestra/plugin/core/trigger/Schedule.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import io.kestra.core.runners.RunContext;
import io.kestra.core.services.ConditionService;
import io.kestra.core.utils.ListUtils;
import io.kestra.core.validations.CronExpression;
import io.kestra.core.validations.ScheduleValidation;
import io.kestra.core.validations.TimezoneId;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
Expand Down Expand Up @@ -120,10 +120,11 @@
},
aliases = "io.kestra.core.models.triggers.types.Schedule"
)
@ScheduleValidation
public class Schedule extends AbstractTrigger implements PollingTriggerInterface, TriggerOutput<Schedule.Output> {
private static final String PLUGIN_PROPERTY_RECOVER_MISSED_SCHEDULES = "recoverMissedSchedules";

public static final CronParser CRON_PARSER = new CronParser(CronDefinitionBuilder.defineCron()
private static final CronDefinitionBuilder CRON_DEFINITION_BUILDER = CronDefinitionBuilder.defineCron()
.withMinutes().withValidRange(0, 59).withStrictRange().and()
.withHours().withValidRange(0, 23).withStrictRange().and()
.withDayOfMonth().withValidRange(1, 31).withStrictRange().and()
Expand All @@ -135,15 +136,15 @@ public class Schedule extends AbstractTrigger implements PollingTriggerInterface
.withSupportedNicknameWeekly()
.withSupportedNicknameDaily()
.withSupportedNicknameMidnight()
.withSupportedNicknameHourly()
.instance()
);
.withSupportedNicknameHourly();

private static final CronParser CRON_PARSER = new CronParser(CRON_DEFINITION_BUILDER.instance());
private static final CronParser CRON_PARSER_WITH_SECONDS = new CronParser(CRON_DEFINITION_BUILDER.withSeconds().withValidRange(0, 59).withStrictRange().and().instance());

@NotNull
@CronExpression
@Schema(
title = "The cron expression.",
description = "A standard [unix cron expression](https://en.wikipedia.org/wiki/Cron) without second.\n" +
description = "A standard [unix cron expression](https://en.wikipedia.org/wiki/Cron) with 5 fields (minutes precision). Using `ẁithSeconds: true` you can switch to 6 fields and a seconds precision.\n" +
"Can also be a cron extension / nickname:\n" +
"* `@yearly`\n" +
"* `@annually`\n" +
Expand All @@ -156,6 +157,15 @@ public class Schedule extends AbstractTrigger implements PollingTriggerInterface
@PluginProperty
private String cron;

@Schema(
title = "Whether the cron expression has seconds precision",
description = "By default, the cron expression has 5 fields, setting this property to true will allow a 6th fields for seconds precision."
)
@NotNull
@Builder.Default
@PluginProperty
private Boolean withSeconds = false;

@TimezoneId
@Schema(
title = "The [time zone identifier](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (i.e. the second column in [the Wikipedia table](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List)) to use for evaluating the cron expression. Default value is the server default zone ID."
Expand Down Expand Up @@ -427,6 +437,11 @@ public RecoverMissedSchedules defaultRecoverMissedSchedules(RunContext runContex
.orElse(RecoverMissedSchedules.ALL);
}

public Cron parseCron() {
CronParser parser = Boolean.TRUE.equals(withSeconds) ? CRON_PARSER_WITH_SECONDS : CRON_PARSER;
return parser.parse(this.cron);
}

private List<Label> generateLabels(ConditionContext conditionContext, Backfill backfill) {
List<Label> labels = new ArrayList<>();

Expand Down Expand Up @@ -477,9 +492,8 @@ private ConditionContext conditionContext(ConditionContext conditionContext, Out

private synchronized ExecutionTime executionTime() {
if (this.executionTime == null) {
Cron parse = CRON_PARSER.parse(this.cron);

this.executionTime = ExecutionTime.forCron(parse);
Cron parsed = parseCron();
this.executionTime = ExecutionTime.forCron(parsed);
}

return this.executionTime;
Expand Down

This file was deleted.

47 changes: 0 additions & 47 deletions core/src/test/java/io/kestra/core/validations/ScheduleTest.java

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package io.kestra.core.validations;

import io.kestra.core.junit.annotations.KestraTest;
import org.junit.jupiter.api.Test;
import io.kestra.plugin.core.trigger.Schedule;
import io.kestra.core.models.validations.ModelValidator;
import io.kestra.core.utils.IdUtils;

import jakarta.inject.Inject;

import java.time.Duration;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.is;

@KestraTest
class ScheduleValidationTest {
@Inject
private ModelValidator modelValidator;

@Test
void cronValidation() throws Exception {
Schedule build = Schedule.builder()
.id(IdUtils.create())
.type(Schedule.class.getName())
.cron("* * * * *")
.build();

assertThat(modelValidator.isValid(build).isEmpty(), is(true));

build = Schedule.builder()
.type(Schedule.class.getName())
.cron("$ome Inv@lid Cr0n")
.build();

assertThat(modelValidator.isValid(build).isPresent(), is(true));
assertThat(modelValidator.isValid(build).get().getMessage(), containsString("invalid cron expression"));
}

@Test
void nicknameValidation() throws Exception {
Schedule build = Schedule.builder()
.id(IdUtils.create())
.type(Schedule.class.getName())
.cron("@hourly")
.build();

assertThat(modelValidator.isValid(build).isEmpty(), is(true));
}

@Test
void withSecondsValidation() throws Exception {
Schedule build = Schedule.builder()
.id(IdUtils.create())
.type(Schedule.class.getName())
.withSeconds(true)
.cron("* * * * * *")
.build();

assertThat(modelValidator.isValid(build).isEmpty(), is(true));

build = Schedule.builder()
.id(IdUtils.create())
.type(Schedule.class.getName())
.cron("* * * * * *")
.build();

assertThat(modelValidator.isValid(build).isPresent(), is(true));
assertThat(modelValidator.isValid(build).get().getMessage(), containsString("invalid cron expression"));
}

@Test
void lateMaximumDelayValidation() {
Schedule build = Schedule.builder()
.id(IdUtils.create())
.type(Schedule.class.getName())
.cron("* * * * *")
.lateMaximumDelay(Duration.ofSeconds(10))
.build();

assertThat(modelValidator.isValid(build).isPresent(), is(false));
}

@Test
void intervalValidation() {
Schedule build = Schedule.builder()
.id(IdUtils.create())
.type(Schedule.class.getName())
.cron("* * * * *")
.interval(Duration.ofSeconds(5))
.build();


assertThat(modelValidator.isValid(build).isPresent(), is(true));
assertThat(modelValidator.isValid(build).get().getMessage(), containsString("interval: must be null"));

}
}
Loading

0 comments on commit fb2732e

Please sign in to comment.