Skip to content

Commit

Permalink
Merge pull request #286 from kagkarlsson/support_disabled_schedules
Browse files Browse the repository at this point in the history
Support for disabled schedules
  • Loading branch information
kagkarlsson authored May 18, 2022
2 parents 9ee3dd9 + 57ea028 commit d865100
Show file tree
Hide file tree
Showing 11 changed files with 263 additions and 9 deletions.
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`.

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ public void apply(SchedulerClient scheduler, Clock clock, Task<T> task) {
final TaskInstance<T> instanceWithoutData = task.instance(this.instance);
final Optional<ScheduledExecution<Object>> 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<Instant> newNextExecutionTime = checkForNewExecutionTime(clock, instanceWithoutData, preexistingExecution.get());

Expand All @@ -62,6 +73,15 @@ public void apply(SchedulerClient scheduler, Clock clock, Task<T> task) {
}
}

private void tryCancel(SchedulerClient scheduler, TaskInstance<T> 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<Instant> checkForNewExecutionTime(Clock clock, TaskInstance<T> instanceWithoutData, ScheduledExecution<Object> preexistingExecution) {
final Instant preexistingExecutionTime = preexistingExecution.getExecutionTime();
final Instant nextExecutionTimeRelativeToNow = schedule.getNextExecutionTime(ExecutionComplete.simulatedSuccess(clock.now()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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<ZonedDateTime> nextExecution(ZonedDateTime date) {
throw unsupportedException();
}

@Override
public Optional<Duration> timeToNextExecution(ZonedDateTime date) {
throw unsupportedException();
}

@Override
public Optional<ZonedDateTime> lastExecution(ZonedDateTime date) {
throw unsupportedException();
}

@Override
public Optional<Duration> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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.");
}
}
Original file line number Diff line number Diff line change
@@ -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<String> EXAMPLES = Collections.singletonList("-");

DisabledScheduleParser() {
super(DISABLED_PATTERN, EXAMPLES);
}

@Override
protected Schedule matchedSchedule(MatchResult matchResult) {
return new DisabledSchedule();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,8 @@ default Instant getInitialExecutionTime(Instant now) {

boolean isDeterministic();

default boolean isDisabled() {
return false;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Void> 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<Void> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down

0 comments on commit d865100

Please sign in to comment.