diff --git a/README.md b/README.md index b8daaf59..8f13eb66 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,7 @@ The library contains a number of Schedule-implementations for recurring tasks. S | ------------- | ------------- | | `.daily(LocalTime ...)` | Runs every day at specified times. Optionally a time zone can be specified. | | `.fixedDelay(Duration)` | Next execution-time is `Duration` after last completed execution. **Note:** This `Schedule` schedules the initial execution to `Instant.now()` when used in `startTasks(...)`| -| `.cron(String)` | Spring-style cron-expression. | +| `.cron(String)` | Spring-style cron-expression. The pattern `-` is interpreted as a [disabled schedule](#disabled-schedules). | Another option to configure schedules is reading string patterns with `Schedules.parse(String)`. @@ -283,9 +283,15 @@ The currently available patterns are: | ------------- | ------------- | | `FIXED_DELAY\|Ns` | Same as `.fixedDelay(Duration)` with duration set to N seconds. | | `DAILY\|12:30,15:30...(\|time_zone)` | Same as `.daily(LocalTime)` with optional time zone (e.g. Europe/Rome, UTC)| +| `-` | [Disabled schedule](#disabled-schedules) | More details on the time zone formats can be found [here](https://docs.oracle.com/javase/8/docs/api/java/time/ZoneId.html#of-java.lang.String-). +### Disabled schedules + +A `Schedule` can be marked as disabled. The scheduler will not schedule the initial executions for tasks with a disabled schedule, +and it will remove any existing executions for that task. + ### Serializers A task-instance may have some associated data in the field `task_data`. The scheduler uses a `Serializer` to read and write this diff --git a/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/helper/ScheduleRecurringOnStartup.java b/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/helper/ScheduleRecurringOnStartup.java index 6f7483f0..913edf9b 100644 --- a/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/helper/ScheduleRecurringOnStartup.java +++ b/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/helper/ScheduleRecurringOnStartup.java @@ -46,6 +46,17 @@ public void apply(SchedulerClient scheduler, Clock clock, Task task) { final TaskInstance instanceWithoutData = task.instance(this.instance); final Optional> preexistingExecution = scheduler.getScheduledExecution(instanceWithoutData); + if (schedule.isDisabled()) { + if (preexistingExecution.isPresent()) { + LOG.info("Task-instance '{}' has a Schedule that has been marked as disabled. Removing existing execution with execution-time '{}'.", + instanceWithoutData, preexistingExecution.get().getExecutionTime()); + tryCancel(scheduler, instanceWithoutData); + } else { + LOG.info("Task-instance '{}' has a Schedule that has been marked as disabled. Will not schedule a new execution", instanceWithoutData); + } + return; + } + if (preexistingExecution.isPresent()) { Optional newNextExecutionTime = checkForNewExecutionTime(clock, instanceWithoutData, preexistingExecution.get()); @@ -62,6 +73,15 @@ public void apply(SchedulerClient scheduler, Clock clock, Task task) { } } + private void tryCancel(SchedulerClient scheduler, TaskInstance instanceWithoutData) { + try { + scheduler.cancel(instanceWithoutData); + } catch (RuntimeException e) { + LOG.warn("Failed to cancel existing execution for a Task with a Scheduled marked as disabled. " + + "May happen if another instance already did it, or if it is currently executing.", e); + } + } + Optional checkForNewExecutionTime(Clock clock, TaskInstance instanceWithoutData, ScheduledExecution preexistingExecution) { final Instant preexistingExecutionTime = preexistingExecution.getExecutionTime(); final Instant nextExecutionTimeRelativeToNow = schedule.getNextExecutionTime(ExecutionComplete.simulatedSuccess(clock.now())); diff --git a/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/schedule/CronSchedule.java b/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/schedule/CronSchedule.java index 134705ed..5031c934 100644 --- a/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/schedule/CronSchedule.java +++ b/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/schedule/CronSchedule.java @@ -24,6 +24,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.time.Duration; import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; @@ -35,25 +36,30 @@ */ public class CronSchedule implements Schedule { + private static final String DISABLED = "-"; private static final Logger LOG = LoggerFactory.getLogger(CronSchedule.class); private final String pattern; private final ZoneId zoneId; private final ExecutionTime cronExecutionTime; + public CronSchedule(String pattern) { + this(pattern, ZoneId.systemDefault()); + } + public CronSchedule(String pattern, ZoneId zoneId) { this.pattern = pattern; - CronParser parser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.SPRING)); - Cron cron = parser.parse(pattern); - this.cronExecutionTime = ExecutionTime.forCron(cron); - if (zoneId == null) { throw new IllegalArgumentException("zoneId may not be null"); } this.zoneId = zoneId; - } - public CronSchedule(String pattern) { - this(pattern, ZoneId.systemDefault()); + if (isDisabled()) { + this.cronExecutionTime = new DisabledScheduleExecutionTime(); + } else { + CronParser parser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.SPRING)); + Cron cron = parser.parse(pattern); + this.cronExecutionTime = ExecutionTime.forCron(cron); + } } @Override @@ -78,4 +84,40 @@ public boolean isDeterministic() { public String toString() { return "CronSchedule pattern=" + pattern + ", zone=" + zoneId; } + + private static class DisabledScheduleExecutionTime implements ExecutionTime { + @Override + public Optional nextExecution(ZonedDateTime date) { + throw unsupportedException(); + } + + @Override + public Optional timeToNextExecution(ZonedDateTime date) { + throw unsupportedException(); + } + + @Override + public Optional lastExecution(ZonedDateTime date) { + throw unsupportedException(); + } + + @Override + public Optional timeFromLastExecution(ZonedDateTime date) { + throw unsupportedException(); + } + + @Override + public boolean isMatch(ZonedDateTime date) { + throw unsupportedException(); + } + + private UnsupportedOperationException unsupportedException() { + return new UnsupportedOperationException("Schedule is marked as disabled. Method should never be called"); + } + } + + @Override + public boolean isDisabled() { + return DISABLED.equals(pattern); + } } diff --git a/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/schedule/DisabledSchedule.java b/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/schedule/DisabledSchedule.java new file mode 100644 index 00000000..e3a7a78c --- /dev/null +++ b/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/schedule/DisabledSchedule.java @@ -0,0 +1,47 @@ +/** + * Copyright (C) Gustav Karlsson + * + * 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 com.github.kagkarlsson.scheduler.task.schedule; + +import com.github.kagkarlsson.scheduler.task.ExecutionComplete; + +import java.time.Instant; + +public class DisabledSchedule implements Schedule{ + + @Override + public Instant getNextExecutionTime(ExecutionComplete executionComplete) { + throw unsupportedException(); + } + + @Override + public boolean isDeterministic() { + throw unsupportedException(); + } + + @Override + public Instant getInitialExecutionTime(Instant now) { + throw unsupportedException(); + } + + @Override + public boolean isDisabled() { + return true; + } + + private UnsupportedOperationException unsupportedException() { + return new UnsupportedOperationException("DisabledSchedule does not support any other operations than 'isDisabled()'. This appears to be a bug."); + } +} diff --git a/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/schedule/DisabledScheduleParser.java b/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/schedule/DisabledScheduleParser.java new file mode 100644 index 00000000..3dedf157 --- /dev/null +++ b/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/schedule/DisabledScheduleParser.java @@ -0,0 +1,35 @@ +/** + * Copyright (C) Gustav Karlsson + * + * 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 com.github.kagkarlsson.scheduler.task.schedule; + +import java.util.Collections; +import java.util.List; +import java.util.regex.MatchResult; +import java.util.regex.Pattern; + +final class DisabledScheduleParser extends RegexBasedParser { + private static final Pattern DISABLED_PATTERN = Pattern.compile("^-$"); + private static final List EXAMPLES = Collections.singletonList("-"); + + DisabledScheduleParser() { + super(DISABLED_PATTERN, EXAMPLES); + } + + @Override + protected Schedule matchedSchedule(MatchResult matchResult) { + return new DisabledSchedule(); + } +} diff --git a/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/schedule/Schedule.java b/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/schedule/Schedule.java index a1687db3..30061bc2 100644 --- a/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/schedule/Schedule.java +++ b/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/schedule/Schedule.java @@ -32,4 +32,8 @@ default Instant getInitialExecutionTime(Instant now) { boolean isDeterministic(); + default boolean isDisabled() { + return false; + } + } diff --git a/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/schedule/Schedules.java b/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/schedule/Schedules.java index 81d12d75..0c302ce5 100644 --- a/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/schedule/Schedules.java +++ b/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/schedule/Schedules.java @@ -21,7 +21,7 @@ import java.util.List; public class Schedules { - private static final Parser SCHEDULE_PARSER = CompositeParser.of(new FixedDelayParser(), new DailyParser()); + private static final Parser SCHEDULE_PARSER = CompositeParser.of(new FixedDelayParser(), new DailyParser(), new DisabledScheduleParser()); public static Daily daily(LocalTime... times) { return new Daily(times); diff --git a/db-scheduler/src/test/java/com/github/kagkarlsson/scheduler/functional/DisabledCronTaskTest.java b/db-scheduler/src/test/java/com/github/kagkarlsson/scheduler/functional/DisabledCronTaskTest.java new file mode 100644 index 00000000..9728bd53 --- /dev/null +++ b/db-scheduler/src/test/java/com/github/kagkarlsson/scheduler/functional/DisabledCronTaskTest.java @@ -0,0 +1,74 @@ +package com.github.kagkarlsson.scheduler.functional; + +import com.github.kagkarlsson.scheduler.EmbeddedPostgresqlExtension; +import com.github.kagkarlsson.scheduler.TestTasks; +import com.github.kagkarlsson.scheduler.task.TaskInstanceId; +import com.github.kagkarlsson.scheduler.task.helper.RecurringTask; +import com.github.kagkarlsson.scheduler.task.helper.Tasks; +import com.github.kagkarlsson.scheduler.task.schedule.Schedules; +import com.github.kagkarlsson.scheduler.testhelper.ManualScheduler; +import com.github.kagkarlsson.scheduler.testhelper.SettableClock; +import com.github.kagkarlsson.scheduler.testhelper.TestHelper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +import static java.util.Collections.singletonList; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class DisabledCronTaskTest { + + public static final String RECURRING_A = "recurring-a"; + private SettableClock clock; + + @RegisterExtension + public EmbeddedPostgresqlExtension postgres = new EmbeddedPostgresqlExtension(); + + @BeforeEach + public void setUp() { + clock = new SettableClock(); + clock.set( + ZonedDateTime.of( + LocalDate.of(2018, 3, 1), + LocalTime.of(8, 0), + ZoneId.systemDefault()).toInstant()); + } + + @Test + public void should_remove_existing_executions_for_tasks_with_disabled_schedule() { + RecurringTask recurringTask = Tasks.recurring(RECURRING_A, Schedules.cron("0 0 12 * * ?")) + .execute(TestTasks.DO_NOTHING); + + ManualScheduler scheduler = manualSchedulerFor(recurringTask); + scheduler.start(); + scheduler.stop(); + + assertTrue(scheduler.getScheduledExecution(TaskInstanceId.of(RECURRING_A, RecurringTask.INSTANCE)).isPresent()); + + RecurringTask disabledRecurringTask = Tasks.recurring(RECURRING_A, Schedules.cron("-")) + .execute(TestTasks.DO_NOTHING); + + ManualScheduler restartedScheduler = manualSchedulerFor(disabledRecurringTask); + restartedScheduler.start(); + restartedScheduler.stop(); + + assertFalse(scheduler.getScheduledExecution(TaskInstanceId.of(RECURRING_A, RecurringTask.INSTANCE)).isPresent()); + + restartedScheduler.start(); + + assertFalse(scheduler.getScheduledExecution(TaskInstanceId.of(RECURRING_A, RecurringTask.INSTANCE)).isPresent()); + } + + private ManualScheduler manualSchedulerFor(RecurringTask recurringTasks) { + return TestHelper.createManualScheduler(postgres.getDataSource()) + .clock(clock) + .startTasks(singletonList(recurringTasks)) + .build(); + } +} diff --git a/db-scheduler/src/test/java/com/github/kagkarlsson/scheduler/task/CronTest.java b/db-scheduler/src/test/java/com/github/kagkarlsson/scheduler/task/CronTest.java index 65819628..728dddb8 100644 --- a/db-scheduler/src/test/java/com/github/kagkarlsson/scheduler/task/CronTest.java +++ b/db-scheduler/src/test/java/com/github/kagkarlsson/scheduler/task/CronTest.java @@ -12,6 +12,8 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; public class CronTest { @@ -70,6 +72,12 @@ public void should_always_use_time_zone() { assertNextExecutionTime(firstJanuaryMiddayUTC, "0 05 13,20 * * ?", newYork, ZonedDateTime.of(2000, 1, 1, 13, 5, 0, 0, newYork)); //next fire time should be 13:05 New York time } + @Test + public void should_mark_schedule_as_disabled() { + assertTrue(Schedules.cron("-").isDisabled()); + assertFalse(Schedules.cron("0 * * * * ?").isDisabled()); + } + private void assertNextExecutionTime(ZonedDateTime timeDone, String cronPattern, ZonedDateTime expectedTime) { assertNextExecutionTime(timeDone, expectedTime, new CronSchedule(cronPattern)); } diff --git a/db-scheduler/src/test/java/com/github/kagkarlsson/scheduler/task/SchedulesTest.java b/db-scheduler/src/test/java/com/github/kagkarlsson/scheduler/task/SchedulesTest.java index 515dec17..f81b0443 100644 --- a/db-scheduler/src/test/java/com/github/kagkarlsson/scheduler/task/SchedulesTest.java +++ b/db-scheduler/src/test/java/com/github/kagkarlsson/scheduler/task/SchedulesTest.java @@ -1,6 +1,7 @@ package com.github.kagkarlsson.scheduler.task; import com.github.kagkarlsson.scheduler.task.schedule.Daily; +import com.github.kagkarlsson.scheduler.task.schedule.DisabledSchedule; import com.github.kagkarlsson.scheduler.task.schedule.FixedDelay; import com.github.kagkarlsson.scheduler.task.schedule.Schedule; import com.github.kagkarlsson.scheduler.task.schedule.Schedules; @@ -50,6 +51,8 @@ public void should_validate_pattern() { Schedule fixedDelaySchedule = assertParsable("FIXED_DELAY|10s", FixedDelay.class); assertThat(fixedDelaySchedule.getNextExecutionTime(complete(NOON_TODAY)), is(NOON_TODAY.plusSeconds(10))); + + assertParsable("-", DisabledSchedule.class); } private ExecutionComplete complete(Instant timeDone) { diff --git a/examples/features/src/main/java/com/github/kagkarlsson/examples/JobChainingPocMain.java b/examples/features/src/main/java/com/github/kagkarlsson/examples/JobChainingPocMain.java index ddb5cb1e..79624dfd 100644 --- a/examples/features/src/main/java/com/github/kagkarlsson/examples/JobChainingPocMain.java +++ b/examples/features/src/main/java/com/github/kagkarlsson/examples/JobChainingPocMain.java @@ -1,3 +1,18 @@ +/** + * Copyright (C) Gustav Karlsson + * + * 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 com.github.kagkarlsson.examples; import com.github.kagkarlsson.examples.helpers.Example;