Custom Spring Boot Scheduler

With the @Schedule annotation, you can very quickly set up recurring tasks in your Spring Boot application. It’s a bit frustrating, though, that you can only choose between two very limited configuration options. Isn’t there more to Spring Boot than that?

Before we build our own scheduler solution, let’s take another look at the two existing options. These are schedulers that use fixedRate and fixedDelay, and schedulers that use cron.

There’s a small but significant difference between the first two. Schedulers that use fixedRate are called at fixed intervals, while schedulers that use fixedDelay have a fixed pause between calls. For schedulers using fixedDelay, it doesn’t matter how long the configured task takes, because a fixed pause is inserted after it finishes. With fixedRate, however, the interval between two calls might be too short, and the task might not have finished yet. This can result in unnecessary extra work, but in the worst case, it can also lead to incorrect data processing.

For schedulers configured with cron, you can use the syntax of cron expressions. This allows schedulers to run at regular intervals in minutes, hours, days, or months, or on specific days of the week.

@Scheduled(cron="0 0/15 8-18 * * 1-5")
public void importLatest() {
  try {
    service.importLatest();
    log.debug("Scheduled import finished: {}", summary);
  } catch (RuntimeException e) {
    log.error("Scheduled import failed", e);
  }
}

For example, the expression “0 0/15 8-18 * * 1-5” in the example means that the job is processed every 15 minutes between 8 a.m. and 6 p.m. from Monday through Friday, while “0 0 */4 * * 1-6” means that the job is processed every 4 hours from Monday through Saturday. So, you can do all sorts of things with cron expressions. But what do we do if we want to use both expressions??

@Scheduled(cron="0 0/15 8-18 * * 1-5")
@Scheduled(cron="0 0 */4 * * 1-6")
public void importLatest() {
  try {
    service.importLatest();
    log.debug("Scheduled import finished: {}", summary);
  } catch (RuntimeException e) {
    log.error("Scheduled import failed", e);
  }
}

Fortunately, the @Scheduled annotation is repeatable, so we can add both expressions to our method, and both will be taken into account. The expressions are combined additively.

As we can see, our method does not run on Sundays because there is no work scheduled for that day and we want to save processing time. Now, however, we’d also like to skip all holidays, since there is no work scheduled on those days either. Unfortunately, this is not an additive combination, so we cannot use another @Scheduled annotation to implement this feature.

Fortunately, behind every simple feature, Spring Boot offers a more advanced variant. In our case, it’s a custom trigger implementation. We’ve already encountered two trigger implementations provided by Spring Boot. These are PeriodicTrigger and CronTrigger, which are hidden behind the @Scheduler annotation.

We will now add two custom triggers. First, the MultiCronTrigger, which allows multiple cron expressions. These are separated by a pipe symbol in the string. To keep things simple, the MultiCronTrigger simply uses a list of CronTriggers to which it delegates the work.

public class MultiCronTrigger implements Trigger {

    private static final String DISABLED = "-";

    private final List<CronTrigger> triggers;

    public MultiCronTrigger(@NonNull String cronExpressions, @NonNull ZoneId zoneId) {
        if (cronExpressions.isBlank() || DISABLED.equals(cronExpressions.trim())) {
            this.triggers = List.of();
            return;
        }
        this.triggers = Arrays.stream(cronExpressions.split("\\|"))
                .map(String::trim)
                .filter(expression -> !expression.isEmpty())
                .map(expression -> new CronTrigger(expression, zoneId))
                .toList();
    }
    @Override
    public @Nullable Instant nextExecution(@NonNull TriggerContext triggerContext) {
        if (triggers.isEmpty()) {
            return null;
        }
        return triggers.stream().map(t -> t.nextExecution(triggerContext))
                .filter(Objects::nonNull)
                .min(Instant::compareTo)
                .orElse(null);
    }
}

The nextExecution method determines when the scheduler will start a task next. For the MultiCronTrigger, this is the earliest time provided by one of its CronTriggers. We determine this in the stream using the min method. With this trigger, we can therefore programmatically achieve exactly what is available with multiple @Scheduled annotations.

We want to use our new MultiCronTrigger in conjunction with the second new trigger. The ExcludeDaysTrigger is designed to remove entire days from the scheduling of another trigger. Its operation is somewhat more complicated than that of its counterpart, which is one of the reasons why both tasks are not implemented by a single trigger.

public class ExcludeDaysTrigger implements Trigger {

    private static final int MAX_SKIPPED = 100;

    private final Trigger wrappedTrigger;
    private final ZoneId zone;
    private final Predicate<LocalDate> skipDay;

    public ExcludeDaysTrigger(@NonNull Trigger wrappedTrigger, ZoneId zone, Predicate<LocalDate> skipDay) {
        this.zone = zone;
        this.skipDay = skipDay;
        this.wrappedTrigger = wrappedTrigger;
    }

    @Override
    public @Nullable Instant nextExecution(@NonNull TriggerContext triggerContext) {
        ZonedDateTime candidate = getBase(triggerContext).atZone(zone);
        for (int i = 0; i < MAX_SKIPPED; i++) {
            SimpleTriggerContext context = new SimpleTriggerContext(candidate.toInstant(), candidate.toInstant(), candidate.toInstant()) {
                @NullMarked
                public Clock getClock() {
                    return triggerContext.getClock();
                }
            };
            Instant next = wrappedTrigger.nextExecution(context);
            if (next == null) {
                return null;
            }
            if (!skipDay.test(next.atZone(zone).toLocalDate())) {
                return next;
            }
            candidate = next.atZone(zone).toLocalDate().plusDays(1).atStartOfDay(zone).minusNanos(1);
        }
        return null;
    }

    // ...
}

The trigger wraps another trigger and is also passed a predicate that specifies, for a given day, whether that day should be removed from the results of the wrapped trigger. Within the nextExecution method, the system first determines when the next scheduled times begin. Within the loop, it first determines the next possible scheduled time and then checks whether it falls on a day that should be ignored.

If the day is not to be ignored, then we have a result and the method terminates. If the day is to be ignored, the program jumps to the last nanosecond of the day and searches for a new schedule time starting from there. We jump to the end of the day because no time on that day can be a possible result. The loop ends after 100 attempts to find a new time. That doesn’t sound like much, but by the end of the loop, we’ve moved at least 100 days into the future. Something like this can happen, for example, with the cron expression “0 0 0 1 1 *”. The scheduler expressions are generated for midnight on January 1 as New Year’s Day. However, since New Year’s Day is a holiday, this expression is immediately deleted.

Now we need to use our two triggers. To do this, we’ll configure them with our own SchedulingConfigurer.

@Configuration
public class FamilyTreeSchedulingConfigurer implements SchedulingConfigurer {

    private final FamilyTreeScheduler scheduler;

    @Value("genealogy.crons:-")
    private String crons;

    public FamilyTreeSchedulingConfigurer(FamilyTreeScheduler scheduler) {
        this.scheduler = scheduler;
    }

    @Override
    public void configureTasks(@NonNull ScheduledTaskRegistrar taskRegistrar) {
        ExcludeDaysTrigger trigger = new ExcludeDaysTrigger(new MultiCronTrigger(crons, ZoneOffset.UTC),
                ZoneOffset.UTC, day -> Holidays.in(Locale.GERMANY).isHoliday(day));
        taskRegistrar.addTriggerTask(scheduler::importLatest, trigger);
    }
}

In the configureTasks method, we create a MultiCronTrigger with a cron expression stored in genealogy.crons and wrap it in an ExcludeDaysTrigger.

genealogy:
  cron: "0 0/15 8-18 * * 1-5|0 0 */4 * * 1-6"

The ExcludeDaysTriggers is passed the expression Holidays.in(Locale.GERMANY).isHoliday(day) as its predicate. Holidays comes from my holidays library and provides the national holidays in Germany via Holidays.in(Locale.GERMANY).

With this configuration, not only Sundays but also all national holidays are now excluded from scheduling

Leave a Comment