Configuring Quartz 2 with Spring in clustered mode

Published:
Reading time:
About 2 min

 

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();
}
}
view raw FirstJob.java hosted with ❤ by GitHub

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/