Configuring Quartz 2 with Spring in clustered mode
Aligning the stars to configure Quartz 2.1.7 to work with Spring 3.1.3 in a cluster was surprisingly complicated. The main idea is to run jobs to fire only once per cluster, not once per server, while still providing beans from the Spring managed context and using the latest version of Quartz. The documentation consists essentially of a number of blog posts and stackoverflow answers. So here is one final and (hopefully) more comprehensive summary of the process.
For the TL;DR version, just see the full github gist.
In Quartz.properties we'll want to set useProperties=true so that data persisted to the DB is in String form instead of Serialized Java objects. But unfortunately the Spring 3.1.x CronTriggerFactoryBean sets a jobDetails property as a Java object, so Quartz will complain that the data is not a String. We'll need to create our own PersistableCronTriggerFactoryBean to get around this issue (similar to this blog post and forum discussion).
/** | |
* Needed to set Quartz useProperties=true when using Spring classes, | |
* because Spring sets an object reference on JobDataMap that is not a String | |
* | |
* @see http://site.trimplement.com/using-spring-and-quartz-with-jobstore-properties/ | |
* @see http://forum.springsource.org/showthread.php?130984-Quartz-error-IOException | |
*/ | |
public class PersistableCronTriggerFactoryBean extends CronTriggerFactoryBean { | |
@Override | |
public void afterPropertiesSet() { | |
super.afterPropertiesSet(); | |
//Remove the JobDetail element | |
getJobDataMap().remove(JobDetailAwareTrigger.JOB_DETAIL_KEY); | |
} | |
} |
# Using Spring datasource in quartzJobsConfig.xml | |
# Spring uses LocalDataSourceJobStore extension of JobStoreCMT | |
org.quartz.jobStore.useProperties=true | |
org.quartz.jobStore.tablePrefix = QRTZ_ | |
org.quartz.jobStore.isClustered = true | |
# Change this to match your DB vendor | |
org.quartz.jobStore.driverDelegateClass = org.quartz.impl.jdbcjobstore.MSSQLDelegate | |
# Needed to manage cluster instances | |
org.quartz.scheduler.instanceId=AUTO | |
org.quartz.scheduler.instanceName=MY_JOB_SCHEDULER | |
org.quartz.scheduler.rmi.export = false | |
org.quartz.scheduler.rmi.proxy = false | |
org.quartz.threadPool.class = org.quartz.simpl.SimpleThreadPool | |
org.quartz.threadPool.threadCount = 10 | |
org.quartz.threadPool.threadPriority = 5 | |
org.quartz.threadPool.threadsInheritContextClassLoaderOfInitializingThread = true |
Additionally, in our Spring config the SchedulerFactoryBean will need to set both the triggers and the jobDetails objects. We also setup the scheduler to use Spring's dataSource and transactionManager. And notice that durability=true must be set on each JobDetailFactoryBean.
<!-- truncated pieces of applicationContext.xml --> | |
<bean id="firstJobDetail" class="org.springframework.scheduling.quartz.JobDetailFactoryBean"> | |
<property name="jobClass" value="com.sheetsj.quartz.job.FirstJob"/> | |
<property name="durability" value="true"/> | |
</bean> | |
<bean id="firstTrigger" class="com.sheetsj.quartz.PersistableCronTriggerFactoryBean"> | |
<property name="jobDetail" ref="firstJobDetail" /> | |
<!-- run every morning at 5:00 AM --> | |
<property name="cronExpression" value="0 0 5 * * ?" /> | |
</bean> | |
<bean id="quartzScheduler" class="org.springframework.scheduling.quartz.SchedulerFactoryBean"> | |
<property name="configLocation" value="classpath:quartz.properties"/> | |
<property name="dataSource" value="dataSource"/> | |
<property name="transactionManager" value="transactionManager"/> | |
<!-- This name is persisted as SCHED_NAME in db. for local testing could change to unique name | |
to avoid collision with dev server --> | |
<property name="schedulerName" value="quartzScheduler"/> | |
<!-- Will update database cron triggers to what is in this jobs file on each deploy. | |
Replaces all previous trigger and job data that was in the database. YMMV --> | |
<property name="overwriteExistingJobs" value="true"/> | |
<property name="autoStartup" value="true"/> | |
<property name="applicationContextSchedulerContextKey" value="applicationContext"/> | |
<property name="jobFactory"> | |
<bean class="com.sheetsj.quartz.AutowiringSpringBeanJobFactory"/> | |
</property> | |
<!-- NOTE: Must add both the jobDetail and trigger to the scheduler! --> | |
<property name="jobDetails"> | |
<list> | |
<ref bean="firstJobDetail" /> | |
</list> | |
</property> | |
<property name="triggers"> | |
<list> | |
<ref bean="firstTrigger"/> | |
</list> | |
</property> | |
</bean> |
By default you cannot use Autowired capabilities in the Quartz Jobs, but this can be easily setup with a AutowiringSpringBeanJobFactory.
/** | |
* Autowire Quartz Jobs with Spring context dependencies | |
* @see http://stackoverflow.com/questions/6990767/inject-bean-reference-into-a-quartz-job-in-spring/15211030#15211030 | |
*/ | |
public final class AutowiringSpringBeanJobFactory extends SpringBeanJobFactory implements ApplicationContextAware { | |
private transient AutowireCapableBeanFactory beanFactory; | |
public void setApplicationContext(final ApplicationContext context) { | |
beanFactory = context.getAutowireCapableBeanFactory(); | |
} | |
@Override | |
protected Object createJobInstance(final TriggerFiredBundle bundle) throws Exception { | |
final Object job = super.createJobInstance(bundle); | |
beanFactory.autowireBean(job); | |
return job; | |
} | |
} |
You'll also notice that we cannot use MethodInvokingJobDetailFactoryBean because it is not serializable, so we need to create our own Job class that extends QuartzJobBean. If your services are secured by Acegi or Spring Security, you will also need to register an authenticated quartzUser object with the security context.
@Component | |
@Scope(value = BeanDefinition.SCOPE_PROTOTYPE) | |
public class FirstJob extends QuartzJobBean { | |
@Autowired | |
private FirstService firstService; | |
@Override | |
protected void executeInternal(JobExecutionContext context) { | |
//Quartz jobs have not been authenticated with acegi or spring security | |
//so you may have to setup a user before calling your service methods | |
//I used SecurityContextHolder.getContext().setAuthentication(quartzUser) | |
//on an older version of acegi | |
firstService.updateSomethingInTheDatabase(); | |
} | |
} |
And finally, we'll want to test that the trigger's Cron expression actually fires when we want it to. Here is an example test case that pulls the cronExpression from configuration and tests that it fires correctly on 2 consecutive days:
/** | |
* Verifies that the Cron Trigger Time Strings for the jobs are setup correctly | |
*/ | |
@ContextConfiguration(locations = {"classpath:/applicationContext-test.xml"}) | |
public class QuartzCronTriggerTest extends AbstractTransactionalJUnit4SpringContextTests { | |
@Autowired | |
private SchedulerFactoryBean quartzScheduler; | |
@Test | |
public void testFirstTrigger() throws SchedulerException { | |
Trigger firstTrigger = quartzScheduler.getScheduler().getTrigger(new TriggerKey("firstTrigger")); | |
//Must use tomorrow for testing because jobs have startTime of now | |
DateTime tomorrow = new DateMidnight().toDateTime().plusDays(1); | |
//Test first | |
Date next = firstTrigger.getFireTimeAfter(tomorrow.toDate()); | |
DateTime expected = tomorrow.plusHours(5); | |
assertThat(next, is(expected.toDate())); | |
//Test the next day | |
next = firstTrigger.getFireTimeAfter(next); | |
expected = expected.plusDays(1); | |
assertThat(next, is(expected.toDate())); | |
} | |
} |
Hopefully this helps others in configuring an enterprise-ready Quartz + Spring application to run jobs in a clustered server environment.
Cross-published on the Object Partners blog: https://objectpartners.com/2013/07/09/configuring-quartz-2-with-spring-in-clustered-mode/