io.jooby.quartz.QuartzModule Maven / Gradle / Ivy
/*
* Jooby https://jooby.io
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
* Copyright 2014 Edgar Espina
*/
package io.jooby.quartz;
import static io.jooby.SneakyThrows.throwingFunction;
import static org.quartz.impl.StdSchedulerFactory.PROP_SCHED_INSTANCE_ID;
import static org.quartz.impl.matchers.GroupMatcher.groupEquals;
import java.lang.reflect.Method;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Properties;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.sql.DataSource;
import org.quartz.JobDetail;
import org.quartz.JobKey;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.Trigger;
import org.quartz.impl.StdSchedulerFactory;
import org.quartz.impl.jdbcjobstore.JobStoreTX;
import org.quartz.simpl.PropertySettingJobFactory;
import org.quartz.utils.DBConnectionManager;
import org.slf4j.Logger;
import com.typesafe.config.Config;
import edu.umd.cs.findbugs.annotations.NonNull;
import io.jooby.Extension;
import io.jooby.Jooby;
import io.jooby.ServiceKey;
import io.jooby.ServiceRegistry;
import io.jooby.SneakyThrows;
import io.jooby.internal.quartz.ConnectionProviderImpl;
import io.jooby.internal.quartz.JobDelegate;
import io.jooby.internal.quartz.JobFactoryImpl;
import io.jooby.internal.quartz.JobGenerator;
/**
* Scheduler module using Quartz: http://www.quartz-scheduler.org.
*
* {@code
* {
* install(new QuartzModule(SampleJob.class));
*
* }
*
* public class SampleJob implements Job {
*
* #64;Scheduled("10m")
* public void execute(JobExecutionContext context) {
*
* }
* }
* }
*
* Implementation of {@link org.quartz.Job} is optional:
*
* {@code
* public class MyJobs {
*
* #64;Scheduled("1m")
* public void everyMinute() {
* ....
* }
*
* #64;Scheduled("1h")
* * public void everyHour() {
* * ....
* * }
* }
* }
*
* Cron expression are supported too. Check the {@link Scheduled} annotation for possible schedule
* expressions.
*
* Job key are generated from container class and method name. Example: SampleJob.execute,
* MyJob.everyMinute, MyJob.everyHour.
*
*
Jobs can be enabled/disabled (paused) at start up time by setting the enabled
* property for each job key:
*
*
* org.quartz.jobs.SampleJob.execute.enabled = false
*
*
* Now the SampleJob.execute
is going to be paused at startup time.
*
* The {@link QuartzApp} added a REST API to trigger, interrupt, pause, resume jobs.
*
* @author edgar
* @since 2.5.1
*/
public class QuartzModule implements Extension {
private List> jobs;
private Scheduler scheduler;
private Boolean cleanStaleJobs;
private boolean cleanJobs;
/**
* Creates Quartz module and register the given jobs.
*
* @param jobs Job classes.
*/
public QuartzModule(final Class>... jobs) {
this.jobs = Arrays.asList(jobs);
}
/**
* Creates Quartz module and register the given jobs.
*
* @param jobs Job classes.
*/
public QuartzModule(final List> jobs) {
this.jobs = jobs;
}
/**
* Creates Quartz module and register the given jobs. Uses an user provided schedule, schedule is
* started at application start up time and shutdown on application shutdown.
*
* @param scheduler Provided scheduler.
* @param jobs Job classes.
*/
public QuartzModule(@NonNull Scheduler scheduler, final Class>... jobs) {
this.scheduler = scheduler;
this.jobs = Arrays.asList(jobs);
}
/**
* Creates Quartz module and register the given jobs. Uses an user provided schedule, schedule is
* started at application start up time and shutdown on application shutdown.
*
* @param scheduler Provided scheduler.
* @param jobs Job classes.
*/
public QuartzModule(@NonNull Scheduler scheduler, final List> jobs) {
this.scheduler = scheduler;
this.jobs = jobs;
}
/**
* Lookup for existing (persisted) jobs and compare with the job list from {@link
* #QuartzModule(Class[])}. Delete any persisted job that is not in the list.
*
* @param cleanStaleJobs True to clear/delete stale job and triggers.
* @return This module.
*/
public QuartzModule cleanStaleJobs(boolean cleanStaleJobs) {
this.cleanStaleJobs = cleanStaleJobs;
return this;
}
/**
* Safely clear/delete all jobs before schedule startup. Use with caution.
*
* @param cleanJobs True to clear/delete all jobs and triggers.
* @return This module.
*/
public QuartzModule cleanJobs(boolean cleanJobs) {
this.cleanJobs = cleanJobs;
return this;
}
@Override
public void install(@NonNull Jooby application) throws Exception {
Config config = application.getConfig();
Map jobMap = JobGenerator.build(application, jobs);
Properties properties = properties(config);
this.cleanStaleJobs = computeCleanStaleJobs(this.scheduler == null);
Scheduler scheduler = this.scheduler == null ? newScheduler(application) : this.scheduler;
var context = scheduler.getContext();
context.put("registry", application);
ServiceRegistry services = application.getServices();
services.putIfAbsent(Scheduler.class, scheduler);
String schedulerName = scheduler.getSchedulerName();
if (services.put(ServiceKey.key(Scheduler.class, schedulerName), scheduler) != null) {
throw new IllegalStateException("Scheduler already exists: " + schedulerName);
}
application.onStarted(
() -> {
if (scheduler.isStarted()) {
scheduler.standby();
}
if (this.cleanJobs) {
// clear all jobs
scheduler.clear();
} else {
if (this.cleanStaleJobs) {
cleanStaleJobs(application.getLog(), scheduler, jobs);
}
}
for (Map.Entry e : jobMap.entrySet()) {
JobDetail jobDetail = e.getKey();
Trigger trigger = e.getValue();
var jobClass = jobDetail.getJobClass();
if (JobDelegate.class.isAssignableFrom(jobClass)) {
var jobMethod = (Method) jobDetail.getJobDataMap().remove("jobMethod");
context.put(jobDetail.getKey().toString(), jobMethod);
}
boolean jobEnabled = isJobPaused(properties, jobDetail.getKey());
if (scheduler.checkExists(jobDetail.getKey())) {
// make sure trigger is updated
scheduler.rescheduleJob(trigger.getKey(), trigger);
} else {
scheduler.scheduleJob(jobDetail, trigger);
}
if (jobEnabled) {
application.getLog().info("{} {}", jobDetail.getKey(), trigger.getDescription());
} else {
scheduler.pauseJob(jobDetail.getKey());
application
.getLog()
.info("{} {} (PAUSED)", jobDetail.getKey(), trigger.getDescription());
}
}
if (!scheduler.isStarted()) {
scheduler.start();
}
});
boolean waitForJobsToComplete =
Boolean.parseBoolean(properties.getProperty("org.quartz.scheduler.waitForJobsToComplete"));
application.onStop(() -> scheduler.shutdown(waitForJobsToComplete));
}
private boolean computeCleanStaleJobs(boolean defaults) {
return this.cleanStaleJobs == null ? defaults : this.cleanStaleJobs.booleanValue();
}
/**
* Cleanup any job that was persisted in previous execution and was removed from job list.
*
* @param log
* @param scheduler
* @param jobs
* @throws SchedulerException
*/
private static void cleanStaleJobs(Logger log, Scheduler scheduler, List> jobs)
throws SchedulerException {
var savedKeys =
scheduler.getJobGroupNames().stream()
.flatMap(throwingFunction(group -> scheduler.getJobKeys(groupEquals(group)).stream()))
.collect(Collectors.toSet());
var activeKeys =
jobs.stream()
.flatMap(job -> JobGenerator.jobMethod(job).stream())
.map(it -> new JobKey(it.getName(), it.getDeclaringClass().getSimpleName()))
.collect(Collectors.toSet());
savedKeys.removeAll(activeKeys);
if (!savedKeys.isEmpty()) {
log.debug("removing stale job(s): {}", savedKeys);
for (JobKey key : savedKeys) {
try {
var deleted = scheduler.deleteJob(key);
log.debug("job {} deleted: {}", key, deleted);
} catch (Exception cause) {
log.debug("unable to delete job: {}", key, cause);
}
}
}
}
/**
* Creates a new scheduler.
*
* @param application Application.
* @return New scheduler.
*/
public static @NonNull Scheduler newScheduler(@NonNull Jooby application) {
try {
Scheduler scheduler = newScheduleFactory(application).getScheduler();
scheduler.setJobFactory(new JobFactoryImpl(application, new PropertySettingJobFactory()));
return scheduler;
} catch (SchedulerException e) {
throw SneakyThrows.propagate(e);
}
}
/**
* Creates a new scheduler factory.
*
* @param application Application.
* @return New scheduler factory.
*/
public static @NonNull StdSchedulerFactory newScheduleFactory(@NonNull Jooby application) {
try {
Properties properties = properties(application.getConfig());
if (JobStoreTX.class.getName().equals(properties.getProperty("org.quartz.jobStore.class"))) {
configureJdbcStore(application, properties);
}
return new StdSchedulerFactory(properties);
} catch (SchedulerException e) {
throw SneakyThrows.propagate(e);
}
}
private static boolean isJobPaused(Properties properties, JobKey key) {
return Stream.of(
"org.quartz.jobs." + key.toString() + ".enabled",
"org.quartz.jobs." + key.getGroup() + ".enabled")
.map(properties::getProperty)
.filter(Objects::nonNull)
.findFirst()
.map(v -> v.equals("true"))
.orElse(true);
}
private static Properties properties(final Config config) {
Properties props = new Properties();
props.setProperty("org.quartz.scheduler.waitForJobsToComplete", "false");
hostName().ifPresent(hostname -> props.setProperty(PROP_SCHED_INSTANCE_ID, hostname));
props.setProperty("org.quartz.scheduler.skipUpdateCheck", "true");
props.setProperty("org.quartz.threadPool.threadNamePrefix", "scheduler");
props.setProperty(
"org.quartz.threadPool.threadCount",
Integer.toString(Runtime.getRuntime().availableProcessors()));
if (config.hasPath("org.quartz")) {
// dump
config
.getConfig("org.quartz")
.entrySet()
.forEach(
e ->
props.setProperty(
"org.quartz." + e.getKey(), e.getValue().unwrapped().toString()));
}
return props;
}
private static Optional hostName() {
try {
return Optional.ofNullable(InetAddress.getLocalHost().getHostName());
} catch (UnknownHostException e) {
return Optional.empty();
}
}
private static void configureJdbcStore(Jooby application, Properties properties) {
String dataSourceName = properties.getProperty("org.quartz.jobStore.dataSource");
ServiceRegistry registry = application.getServices();
DataSource dataSource =
Optional.ofNullable(dataSourceName)
.map(key -> registry.getOrNull(ServiceKey.key(DataSource.class, key)))
.orElseGet(() -> registry.getOrNull(DataSource.class));
if (dataSource == null) {
// TODO: replace with usage exception
throw new IllegalArgumentException("DataSource not found: " + dataSourceName);
}
String dataSourceKey = Optional.ofNullable(dataSourceName).orElse("db");
properties.setProperty("org.quartz.jobStore.dataSource", dataSourceKey);
DBConnectionManager.getInstance()
.addConnectionProvider(dataSourceKey, new ConnectionProviderImpl(dataSource));
}
}