Dynamic Task Scheduling with Spring
Summary
Spring makes it very easy to schedule a job to run periodically. All we need to do is to put @Scheduled annotation above the method and provide the necessary parameters such as fixedRate or cron expression. But when it comes to change this fixedRate on the fly, @Scheduled
annotation is not enough. Ultimately, what I wanted to do was periodically load my configuration table from a database. But the fixedRate
which indicates how frequently I will load this table is also stored in the very same database. So what I wanted to do was reading this value from a database and schedule the task according to it. Whenever the value changes, the next execution time should change with it too.
Before going into next step, I also created a repository for all the code in this tutorial to show how this scheduling works. You can find the example code in my Github page
Also at the end I will add an alternative way for scheduling with exact date and a way to start the scheduler from external service (like controller).
Please check the above repository for various scheduling examples.
Loading the value from properties file
First of all, in @Scheduled
annotation you can only use constant values. To use Spring’s Scheduler, you need to put @EnableScheduling
annotation above any of your class. I prefer my Main Application class for that purpose, but any of the classes should work. You can retrieve the value for this scheduler from your properties file. Such as;
@Scheduled(fixedRateString = "${scheduler.configurationLoadRate}")
public void loadConfigurations() {
...
}
scheduler.configurationLoadRate
is the property I have defined in my property file.
scheduler.configurationLoadRate=3600000
But this brings another problem. Whenever I want to change this value, I have to restart the application, which is not very convenient. I wanted to inject a value from a database but we can’t store a value in a variable and use in the annotation because it only accepts constant values. Later I have discovered a way to use values from a database;
Loading the value from a database
First I am creating a bean which retrieves data from the database whenever it is called. And then giving this bean to my @Scheduled
annotation using SpEL, so-called Spring Expression Language.
@Bean
public String getConfigRefreshValue() {
return configRepository.findOne(Constants.CONFIG_KEY_REFRESH_RATE).getConfigValue();
}
.
.
.
@Scheduled(fixedRateString = "#{@getConfigRefreshValue}")
public void loadConfigurations() {
...
}
This works like a charm but Houston, we have a problem. This @Scheduled
annotation only looks at the fixedRate once and never looks at it again. So even if the value changes, it doesn’t care. I mean if all you want is to retrieve this data from a database, you can go with this solution. But I realised that dynamic task scheduling with Spring can not be done by @Scheduled
annotation. So after some search I decided to create my own Scheduler Service that implements SchedulerConfigurer which successfully changed the rate whenever the data changes. You can find the solution below.
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.Trigger;
import org.springframework.scheduling.TriggerContext;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
import org.springframework.stereotype.Service;
@Service
public class SchedulerService implements SchedulingConfigurer {
@Autowired
ConfigurationService configurationService;
@Bean
public TaskScheduler poolScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setThreadNamePrefix("ThreadPoolTaskScheduler");
scheduler.setPoolSize(1);
scheduler.initialize();
return scheduler;
}
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.setScheduler(poolScheduler());
taskRegistrar.addTriggerTask(new Runnable() {
@Override
public void run() {
// Do not put @Scheduled annotation above this method, we don't need it anymore.
configurationService.loadConfigurations();
}
}, new Trigger() {
@Override
public Date nextExecutionTime(TriggerContext triggerContext) {
Calendar nextExecutionTime = new GregorianCalendar();
Date lastActualExecutionTime = triggerContext.lastActualExecutionTime();
nextExecutionTime.setTime(lastActualExecutionTime != null ? lastActualExecutionTime : new Date());
nextExecutionTime.add(Calendar.MILLISECOND, Integer.parseInt(configurationService.getConfiguration(Constants.CONFIG_KEY_REFRESH_RATE_CONFIG).getConfigValue()));
return nextExecutionTime.getTime();
}
});
}
}
We can also write the same function with lambda expressions which will be more compact;
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.setScheduler(poolScheduler());
taskRegistrar.addTriggerTask(() -> configurationService.loadConfigurations(), t -> {
Calendar nextExecutionTime = new GregorianCalendar();
Date lastActualExecutionTime = t.lastActualExecutionTime();
nextExecutionTime.setTime(lastActualExecutionTime != null ? lastActualExecutionTime : new Date());
nextExecutionTime.add(Calendar.MILLISECOND,
Integer.parseInt(configurationService.getConfiguration(Constants.CONFIG_KEY_REFRESH_RATE_CONFIG).getConfigValue()));
return nextExecutionTime.getTime();
});
}
I tried to add cancelling and re-activating feature to the Scheduler. With little tweak to above code we can achieve it, but I am not sure if it is the optimal solution or not, so use it at your own risk:
package com.mbcoder.scheduler.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
import org.springframework.stereotype.Service;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.concurrent.ScheduledFuture;
/**
* Alternative version for DynamicScheduler
* This one should support everything the basic dynamic scheduler does,
* and on top of it, you can cancel and re-activate the scheduler.
*/
@Service
public class CancellableScheduler implements SchedulingConfigurer {
private static Logger LOGGER = LoggerFactory.getLogger(DynamicScheduler.class);
ScheduledTaskRegistrar scheduledTaskRegistrar;
ScheduledFuture future;
@Bean
public TaskScheduler poolScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setThreadNamePrefix("ThreadPoolTaskScheduler");
scheduler.setPoolSize(1);
scheduler.initialize();
return scheduler;
}
// We can have multiple tasks inside the same registrar as we can see below.
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
if (scheduledTaskRegistrar == null) {
scheduledTaskRegistrar = taskRegistrar;
}
if (taskRegistrar.getScheduler() == null) {
taskRegistrar.setScheduler(poolScheduler());
}
future = taskRegistrar.getScheduler().schedule(() -> scheduleFixed(), t -> {
Calendar nextExecutionTime = new GregorianCalendar();
Date lastActualExecutionTime = t.lastActualExecutionTime();
nextExecutionTime.setTime(lastActualExecutionTime != null ? lastActualExecutionTime : new Date());
nextExecutionTime.add(Calendar.SECOND, 7);
return nextExecutionTime.getTime();
});
// or cron way
taskRegistrar.addTriggerTask(() -> scheduleCron(repo.findById("next_exec_time").get().getConfigValue()), t -> {
CronTrigger crontrigger = new CronTrigger(repo.findById("next_exec_time").get().getConfigValue());
return crontrigger.nextExecutionTime(t);
});
}
public void scheduleFixed() {
LOGGER.info("scheduleFixed: Next execution time of this will always be 5 seconds");
}
public void scheduleCron(String cron) {
LOGGER.info("scheduleCron: Next execution time of this taken from cron expression -> {}", cron);
}
/**
* @param mayInterruptIfRunning {@code true} if the thread executing this task
* should be interrupted; otherwise, in-progress tasks are allowed to complete
*/
public void cancelTasks(boolean mayInterruptIfRunning) {
LOGGER.info("Cancelling all tasks");
future.cancel(mayInterruptIfRunning); // set to false if you want the running task to be completed first.
}
public void activateScheduler() {
LOGGER.info("Re-Activating Scheduler");
configureTasks(scheduledTaskRegistrar);
}
}
We don’t have to keep the reference to future, we can add and remove jobs from external service like;
package com.mbcoder.scheduler.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.ScheduledFuture;
@Service
public class ExternalScheduler implements SchedulingConfigurer {
private static Logger LOGGER = LoggerFactory.getLogger(ExternalScheduler.class);
ScheduledTaskRegistrar scheduledTaskRegistrar;
Map<String, ScheduledFuture> futureMap = new HashMap<>();
@Bean
public TaskScheduler poolScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setThreadNamePrefix("ThreadPoolTaskScheduler");
scheduler.setPoolSize(1);
scheduler.initialize();
return scheduler;
}
// Initially scheduler has no job
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
if (scheduledTaskRegistrar == null) {
scheduledTaskRegistrar = taskRegistrar;
}
if (taskRegistrar.getScheduler() == null) {
taskRegistrar.setScheduler(poolScheduler());
}
}
public boolean addJob(String jobName) {
if (futureMap.containsKey(jobName)) {
return false;
}
ScheduledFuture future = scheduledTaskRegistrar.getScheduler().schedule(() -> methodToBeExecuted(), t -> {
Calendar nextExecutionTime = new GregorianCalendar();
Date lastActualExecutionTime = t.lastActualExecutionTime();
nextExecutionTime.setTime(lastActualExecutionTime != null ? lastActualExecutionTime : new Date());
nextExecutionTime.add(Calendar.SECOND, 5);
return nextExecutionTime.getTime();
});
configureTasks(scheduledTaskRegistrar);
futureMap.put(jobName, future);
return true;
}
public boolean removeJob(String name) {
if (!futureMap.containsKey(name)) {
return false;
}
ScheduledFuture future = futureMap.get(name);
future.cancel(true);
futureMap.remove(name);
return true;
}
public void methodToBeExecuted() {
LOGGER.info("methodToBeExecuted: Next execution time of this will always be 5 seconds");
}
}
Since some of you asked for the code of my ConfigurationService, I decided to post the code here. Below you can find the implementation of Configuration model, ConfigRepository, ConfigurationService and Constants:
@Entity
public class Configuration {
@Id
@Size(max = 128)
String configKey;
@Size(max = 512)
@NotNull
String configValue;
public Configuration() {
}
public Configuration(String configKey, String configValue) {
this.configKey = configKey;
this.configValue = configValue;
}
public String getConfigKey() {
return configKey;
}
public void setConfigKey(String configKey) {
this.configKey = configKey;
}
public String getConfigValue() {
return configValue;
}
public void setConfigValue(String configValue) {
this.configValue = configValue;
}
}
public interface ConfigRepository extends JpaRepository<Configuration, String> {
}
/**
* ConfigurationService is responsible for loading and checking configuration parameters.
*
* @author mbcoder
*
*/
@Service
public class ConfigurationService {
private static final Logger LOGGER = LoggerFactory.getLogger(ConfigurationService.class);
ConfigRepository configRepository;
private Map<String, Configuration> configurationList;
private List<String> mandatoryConfigs;
@Autowired
public ConfigurationService(ConfigRepository configRepository) {
this.configRepository = configRepository;
this.configurationList = new ConcurrentHashMap<>();
this.mandatoryConfigs = new ArrayList<>();
this.mandatoryConfigs.add(Constants.CONFIG_KEY_REFRESH_RATE_CONFIG);
this.mandatoryConfigs.add(Constants.CONFIG_KEY_REFRESH_RATE_METRIC);
this.mandatoryConfigs.add(Constants.CONFIG_KEY_REFRESH_RATE_TOKEN);
this.mandatoryConfigs.add(Constants.CONFIG_KEY_REFRESH_RATE_USER);
}
/**
* Loads configuration parameters from Database
*/
@PostConstruct
public void loadConfigurations() {
LOGGER.debug("Scheduled Event: Configuration table loaded/updated from database");
StringBuilder sb = new StringBuilder();
sb.append("Configuration Parameters:");
List<Configuration> configs = configRepository.findAll();
for (Configuration configuration : configs) {
sb.append("\n" + configuration.getConfigKey() + ":" + configuration.getConfigValue());
this.configurationList.put(configuration.getConfigKey(), configuration);
}
LOGGER.debug(sb.toString());
checkMandatoryConfigurations();
}
public Configuration getConfiguration(String key) {
return configurationList.get(key);
}
/**
* Checks if the mandatory parameters are exists in Database
*/
public void checkMandatoryConfigurations() {
for (String mandatoryConfig : mandatoryConfigs) {
boolean exists = false;
for (Map.Entry<String, Configuration> pair : configurationList.entrySet()) {
if (pair.getKey().equalsIgnoreCase(mandatoryConfig) && !pair.getValue().getConfigValue().isEmpty()) {
exists = true;
}
}
if (!exists) {
String errorLog = String.format("A mandatory Configuration parameter is not found in DB: %s", mandatoryConfig);
LOGGER.error(errorLog);
}
}
}
}
Alternatively we can also do the scheduling by giving the exact date, in that case we don’t need to know previous execution time. For example:
// startDate and endDate are only calendar date and time indicates at which hour/minute of the day.
public void scheduleAt(LocalDate startDate, LocalDate endDate, LocalTime time) {
LocalDate now = LocalDate.now();
if (now.isBefore(endDate)) {
if (now.isBefore(startDate)) {
now = startDate;
}
LocalDateTime current = now.atTime(time);
ZoneId zone = ZoneId.of("Europe/Berlin");
ZoneOffset zoneOffSet = zone.getRules().getOffset(current);
Instant nextRunTime = current.toInstant(zoneOffSet);
poolScheduler().schedule(() -> realMethod(), nextRunTime);
}
}
public void realMethod() {
// This is your real code to be scheduled
}
Final Words
You can expand this solution to run this every day for example, and you can load start/end dates from database.
Don’t forget to check my Github repository to have a better understanding of how this code looks like, and also if this helped you, feel free to give the repository a star 🙂
All this solutions are my own interpretation. For production level usage, you may want to use a scheduling library such as Jesque.