Repository: kestra-io/kestra (java)

Issue:
Please add support for standard unix cron expression with SECOND field.
### Feature description

In my use cases, I really need to specify the SECOND field. I hope cron element of kestra accepting 6 fields.
According to the document, `Internally, the Scheduler will keep checking every second whether some trigger must be evaluated.`

Should be an easy change for kestra?

Pull request description:
feat(core): Schedule with seconds
Fixes #3941


Patch:
diff --git a/core/src/main/java/io/kestra/core/validations/CronExpression.java b/core/src/main/java/io/kestra/core/validations/ScheduleValidation.java
similarity index 65%
rename from core/src/main/java/io/kestra/core/validations/CronExpression.java
rename to core/src/main/java/io/kestra/core/validations/ScheduleValidation.java
index eac3c5842..bba77fb0b 100644
--- a/core/src/main/java/io/kestra/core/validations/CronExpression.java
+++ b/core/src/main/java/io/kestra/core/validations/ScheduleValidation.java
@@ -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 {};
 }
\ No newline at end of file
diff --git a/core/src/main/java/io/kestra/core/validations/validator/CronExpressionValidator.java b/core/src/main/java/io/kestra/core/validations/validator/CronExpressionValidator.java
deleted file mode 100644
index d56a267a1..000000000
--- a/core/src/main/java/io/kestra/core/validations/validator/CronExpressionValidator.java
+++ /dev/null
@@ -1,38 +0,0 @@
-package io.kestra.core.validations.validator;
-
-import com.cronutils.model.Cron;
-import io.kestra.core.validations.CronExpression;
-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 CronExpressionValidator implements ConstraintValidator<CronExpression, String> {
-    @Override
-    public boolean isValid(
-        @Nullable String value,
-        @NonNull AnnotationValue<CronExpression> annotationMetadata,
-        @NonNull ConstraintValidatorContext context) {
-        if (value == null) {
-            return true;
-        }
-
-        try {
-            Cron parse = Schedule.CRON_PARSER.parse(value);
-            parse.validate();
-        } catch (IllegalArgumentException e) {
-            context.disableDefaultConstraintViolation();
-            context.buildConstraintViolationWithTemplate( "invalid cron expression '({validatedValue})': " + e.getMessage())
-                .addConstraintViolation();
-            return false;
-        }
-
-        return true;
-    }
-}
diff --git a/core/src/main/java/io/kestra/core/validations/validator/ScheduleValidator.java b/core/src/main/java/io/kestra/core/validations/validator/ScheduleValidator.java
new file mode 100644
index 000000000..8c79c1623
--- /dev/null
+++ b/core/src/main/java/io/kestra/core/validations/validator/ScheduleValidator.java
@@ -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;
+    }
+}
diff --git a/core/src/main/java/io/kestra/plugin/core/trigger/Schedule.java b/core/src/main/java/io/kestra/plugin/core/trigger/Schedule.java
index c38197bbf..14ab67fcd 100644
--- a/core/src/main/java/io/kestra/plugin/core/trigger/Schedule.java
+++ b/core/src/main/java/io/kestra/plugin/core/trigger/Schedule.java
@@ -22,7 +22,7 @@ import io.kestra.core.runners.DefaultRunContext;
 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;
@@ -120,10 +120,11 @@ import java.util.stream.Stream;
     },
     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()
@@ -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" +
@@ -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."
@@ -427,6 +437,11 @@ public class Schedule extends AbstractTrigger implements PollingTriggerInterface
             .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<>();
 
@@ -477,9 +492,8 @@ public class Schedule extends AbstractTrigger implements PollingTriggerInterface
 
     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;


Test patch:
diff --git a/core/src/test/java/io/kestra/core/validations/CronExpressionTest.java b/core/src/test/java/io/kestra/core/validations/CronExpressionTest.java
deleted file mode 100644
index 86f694b96..000000000
--- a/core/src/test/java/io/kestra/core/validations/CronExpressionTest.java
+++ /dev/null
@@ -1,49 +0,0 @@
-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 static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.Matchers.containsString;
-import static org.hamcrest.Matchers.is;
-
-@KestraTest
-class CronExpressionTest {
-    @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));
-    }
-}
diff --git a/core/src/test/java/io/kestra/core/validations/ScheduleTest.java b/core/src/test/java/io/kestra/core/validations/ScheduleTest.java
deleted file mode 100644
index d1ea4fd40..000000000
--- a/core/src/test/java/io/kestra/core/validations/ScheduleTest.java
+++ /dev/null
@@ -1,47 +0,0 @@
-package io.kestra.core.validations;
-
-import io.kestra.plugin.core.trigger.Schedule;
-import io.kestra.core.models.validations.ModelValidator;
-import io.kestra.core.utils.IdUtils;
-import io.kestra.core.junit.annotations.KestraTest;
-import jakarta.inject.Inject;
-import org.junit.jupiter.api.Test;
-
-import java.time.Duration;
-
-import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.Matchers.containsString;
-import static org.hamcrest.Matchers.is;
-
-@KestraTest
-class ScheduleTest {
-    @Inject
-    private ModelValidator modelValidator;
-
-    @Test
-    void cronValidation()  {
-        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"));
-
-    }
-}
diff --git a/core/src/test/java/io/kestra/core/validations/ScheduleValidationTest.java b/core/src/test/java/io/kestra/core/validations/ScheduleValidationTest.java
new file mode 100644
index 000000000..a6fad0b3a
--- /dev/null
+++ b/core/src/test/java/io/kestra/core/validations/ScheduleValidationTest.java
@@ -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"));
+
+    }
+}
diff --git a/core/src/test/java/io/kestra/plugin/core/trigger/ScheduleTest.java b/core/src/test/java/io/kestra/plugin/core/trigger/ScheduleTest.java
index bd764b360..e9f184435 100644
--- a/core/src/test/java/io/kestra/plugin/core/trigger/ScheduleTest.java
+++ b/core/src/test/java/io/kestra/plugin/core/trigger/ScheduleTest.java
@@ -163,6 +163,29 @@ class ScheduleTest {
         assertThat(dateFromVars(vars.get("previous"), date), is(date.minus(Duration.ofMinutes(1))));
     }
 
+    @Test
+    void everySecond() throws Exception {
+        Schedule trigger = Schedule.builder().id("schedule").cron("* * * * * *").withSeconds(true).build();
+
+        ZonedDateTime date = ZonedDateTime.now()
+            .truncatedTo(ChronoUnit.SECONDS)
+            .minus(Duration.ofSeconds(1));
+
+        Optional<Execution> evaluate = trigger.evaluate(
+            conditionContext(trigger),
+            triggerContext(date, trigger)
+        );
+
+        assertThat(evaluate.isPresent(), is(true));
+
+        var vars = (Map<String, String>) evaluate.get().getVariables().get("schedule");
+
+
+        assertThat(dateFromVars(vars.get("date"), date), is(date));
+        assertThat(dateFromVars(vars.get("next"), date), is(date.plus(Duration.ofSeconds(1))));
+        assertThat(dateFromVars(vars.get("previous"), date), is(date.minus(Duration.ofSeconds(1))));
+    }
+
     @Test
     void noBackfillNextDate() throws Exception {
         Schedule trigger = Schedule.builder().id("schedule").cron("0 0 * * *").build();