Un peu plus loin avec Quartz
Quartz, la plupart des développeurs en ont au moins entendu parler: c’est un planificateur de tâches en Java, initié par feu OpenSymphony et aujourd’hui maintenu par Terracotta. Cet article donne quelques astuces d’utilisation, qui pourraient faire suite à la documentation Spring Scheduling.
Les concepts
Commençons par un bref rappel des concepts Quartz:
- Trigger: le quand exécuter, ça décrit une ou série de moments, il y a plusieurs variantes: Cron, périodique, one shot…
- Job: le quoi exécuter, c’est un bout de code
- Job Detail: l’instance de Job, elle a un ou plusieurs triggers et éventuellement des paramètres
- Job Group: un ensemble de Job Details ou de Triggers que l’on manipule ensemble: arrêt, démarrage, annulation…
- Scheduler: le moteur chargé de la planification et de l’exécution des tâches planifiées
Ces concepts se traduisent en beans Spring:
<bean id="mailSimpleTrigger" class="org.springframework.scheduling.quartz.SimpleTriggerFactoryBean"> <property name="jobDetail" ref="mailJobDetail" /> <property name="startDelay" value="60000" /> <!-- 1 minute après le démarrage --> <property name="repeatInterval" value="360000" /> <!-- puis toutes les heures --> </bean> <bean id="mailCronTrigger" class="org.springframework.scheduling.quartz.CronTriggerFactoryBean"> <property name="jobDetail" ref="mailJobDetail" /> <property name="cronExpression" value="0 0 4 * * ?" /> <!-- tous les jours à 4h du matin --> </bean> <bean id="mailJobDetail" class="org.springframework.scheduling.quartz.MethodInvokingJobDetailFactoryBean"> <property name="targetObject" ref="mailService" /> <property name="targetMethod" value="envoyerMail" /> </bean> <bean id="scheduler" class="org.springframework.scheduling.quartz.SchedulerFactoryBean"> <property name="triggers"><list> <ref bean="mailSimpleTrigger" /> <ref bean="mailCronTrigger" /> </list></property> </bean>
Cette configuration invoquera la méthode envoyerMail
du bean Spring mailService
toutes les heures (mailSimpleTrigger
) et tous les jours à 4h (mailCronTrigger
).
Injection de dépendances (et de paramètres)
Au lieu d’utiliser le MethodInvokingJobDetailFactoryBean
Spring comme ci-dessus, on peut créer ses propres classes de Job:
public class EnvoyerMailJob implements Job { private MailService mailService; private int idVol; // Getters + Setters public void execute(JobExecutionContext context) { Date date=context.getScheduledFireTime(); mailService.envoyerMail(date, idVol); } }
On le déclare dans la configuration Spring comme auparavant:
<bean id="envoyerMailJob" class="org.springframework.scheduling.quartz.JobDetailBean"> <property name="jobClass" value="com.mycompany.test.quartz.EnvoyerMailJob"/> <property name="jobDataAsMap"><map> <entry key="idJob" value="456" /> </map></property> </bean>
Au moment voulu, Quartz va instancier cette classe EnvoyerMailJob
et invoquer la méthode execute
. Pour que l’injection de dépendances Spring s’applique à cet objet (en particulier mailService
), il y a plusieurs stratégies:
- Utiliser AspectJ et la modification de bytecode,
- Faire hériter le Job de la classe
QuartzJobBean
, - Remplacer la
JobFactory
que Quartz utilise pour instancier les Jobs.
C’est cette dernière stratégie que nous allons utiliser, il faut ajouter dans la configuration du Scheduler:
- la propriété
jobFactory
recevra unSpringBeanJobFactory
. Cette implémentation deJobFactory
injecte dans le Job les paramètres du Job ainsi que le beans présents dans le contexte du scheduler. - la propriété
schedulerContextAsMap
ajoutera des beans Spring dans le contexte du scheduler.
<bean id="scheduler" class="org.springframework.scheduling.quartz.SchedulerFactoryBean"> <property name="jobFactory"> <bean class="org.springframework.scheduling.quartz.SpringBeanJobFactory"/> </property> <property name="schedulerContextAsMap"><map> <entry key="mailService" value-ref="mailService"/> </map></property> </bean>
Il est possible d’étendre la SpringBeanJobFactory
comme cela est fait ici, pour qu’elle injecte directement les beans du contexte Spring, on s’évite ainsi de remplir le contexte du scheduler. La documentation Spring fait allusion à la classe QuartzJobBean
, avec Quartz<1.5 le concept de JobFactory n’existait pas, procéder par héritage était alors l’unique solution. Mais aujourd’hui la SpringBeanJobFactory
est un moyen bien plus élégant de faire la même chose. Il faut toutefois noter que les instances de Job utilisées à l’exécution (comme EnvoyerMailJob
) ne sont pas des beans Spring à part entière: leur cycle de vie n’est pas géré par la BeanFactory
Spring et l’AOP ne s’applique pas dessus.
Persistance des Jobs
On ne souhaite pas que la définition des Jobs soit figée dans la configuration Spring, et on veut pouvoir en ajouter de nouveaux à l’exécution. Par exemple, on souhaite envoyer un mail de rappel à tous les clients qui ont réservé une place sur un des vols de notre compagnie, 4h avant l’embarquement. A chaque création de vol, il faut ajouter une nouvelle tâche planifiée dont l’heure dépend de celle du vol. Ce genre de cas d’utilisation est très fréquent dans les workflows où il faut rappeler à l’utilisateur la deadline d’une tâche qui lui incombe ou bien passer la tâche dans un nouvel état (expiration).
Quartz abstrait le stockage des Jobs dans le concept de JobStore: il existe un RAM JobStore pour les stocker en mémoire, un JDBC JobStore pour les stocker dans une base de données via JDBC, et un Terracotta JobStore pour les stocker dans un cluster du même nom. Nous opterons pour du JDBC car c’est simple à mettre en oeuvre et ultra classique. Dans une base de données relationnelle, on va donc créer un jeu de tables Quartz; des exemples de scripts SQL de création figurent dans le dossier docsdbTables
de l’archive quartz-2.2.0-distribution.tar.gz. Puis on déclare une DataSource de manière habituelle et on l’injecte dans la SchedulerFactory
:
<bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource" destroy-method="close"> ... </bean> <bean id="scheduler" class="org.springframework.scheduling.quartz.SchedulerFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="quartzProperties"><props> <prop key="org.quartz.jobStore.driverDelegateClass">org.quartz.impl.jdbcjobstore.PostgreSQLDelegate</prop> <prop key="org.quartz.jobStore.tablePrefix">QUARTZ_</prop> </property> </bean>
Il est inutile de configurer soi-même le type de JobStore, Spring s’en charge et positionne une variante à lui du
JDBC JobStore, le LocalDataSourceJobStore. On peut éventuellement configurer le “dialecte” SQL utilisé (appelé DriverDelegate) ainsi que le préfixe des noms de tables. Le cas échéant, le StdJDBCDelegate
s’applique automatiquement et suffit généralement.
Pour planifier un Job dynamiquement, on ne peut pas utiliser le MethodInvokingJobDetailFactoryBean
car il n’est pas persistable, il faut impérativement passer par des classes de Job spécifiques. On peut à présent planifier le Job programmatiquement:
public void planifierEnvoiMail(int idVol, Date date) throws SchedulerException { JobDetail jobDetail = JobBuilder.newJob(EnvoyerMailJob.class) .withIdentity("EnvoiMailJob#"+idVol, "EnvoiMailJobs") .usingJobData("idVol", idVol) .build(); Trigger trigger = TriggerBuilder.newTrigger() .withIdentity("EnvoiMailTrig#"+idVol, "EnvoiMailTrigs") .forJob(jobDetail) .startAt(date) .build(); quartzScheduler.scheduleJob(jobDetail, trigger); }
Transactions
Aussitôt une base de données mise en place, se pose la question des transactions. Avec Quartz, elles interviennent à plusieurs moments:
- La planification et le contrôle (pause/reprise) d’un Job: la création d’un nouveau vol s’accompagne de la planification d’un nouvel envoi de mail, les deux doivent rester cohérents.
- L’exécution d’un Job: le code métier contenu dans le Job lui-même peut être transactionnel
- La surveillance et la mise à jour du Job: le scheduler va surveiller les Jobs a exécuter. puis modifier l’état du Job avant et après son exécution. Ces accès doivent être faits dans des transactions séparées de l’exécution.
Planification et contrôle d’un Job
A ce niveau, Spring est maître de la démarcation transactionnelle.
Si on procède de manière impérative, il faudra juste s’assurer que le service métier correspondant est transactionnel. On ajoutera par exemple @Transactional
sur la méthode planifierEnvoiMail
dans l’exemple ci-dessus. Si on procède de manière déclarative, il faudra injecter le transaction manager au SchedulerFactoryBean
:
<bean id="quartzScheduler" class="org.springframework.scheduling.quartz.SchedulerFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="transactionManager" ref="transactionManager"/> <property name="triggers"> ... </property> </bean>
Attention, la propriété transactionManager
est seulement utilisée par Spring pour planifier les Jobs au démarrage. A l’exécution, Quartz ne sait pas s’appuyer sur le PlatformTransactionManager.
Exécution d’un Job
Pour rendre l’exécution d’un Job transactionnel, il y a deux stratégies:
- soit Quartz gère la transaction: l’utilisation de JTA et d’un transaction manager XA sont alors nécessaires,
- soit Spring gère la transaction: le Job lui même ne sera pas transactionnel, mais en déléguant à un bean Spring tout le traitement, le résultat sera le même.
Pour amener Quartz à envelopper l’exécution d’un Job dans une transaction JTA, il faut juste activer une propriété:
<bean id="quartzScheduler" class="org.springframework.scheduling.quartz.SchedulerFactoryBean"> <property name="quartzProperties"><props> <prop key="org.quartz.scheduler.wrapJobExecutionInUserTransaction">true</prop> ... </props></property> </bean>
ou bien mettre une annotation @ExecuteInJTATransaction
sur le Job.
Surveillance et mise à jour de l’état d’un Job
Ici Quartz est forcément maître de la transaction. Quelque soit la configuration, il ne se sert pas d’une transaction JTA, il va piloter la transaction au niveau JDBC comme le DataSourceTransactionManager de Spring. Bref, il lui faut une DataSource non XA.
<bean id="quartzScheduler" class="org.springframework.scheduling.quartz.SchedulerFactoryBean"> <property name="dataSource" ref="xaDataSource"/> <property name="nonTransactionalDataSource" ref="nonXaDataSource"/> ... </bean>
Dans la configuration du SchedulerFactoryBean
, on peut injecter deux DataSources:
dataSource
est utilisée lors de la planification des Jobs, elle peut être XA ou pas, elle est obligatoire. Quartz ne s’occupe pas des transactions dessus, il faut le faire en amont avec Spring, au choix en JTA ou en JDBC.nonTransactionalDataSource
est utilisée pour surveiller et mettre à jour les Jobs lors de leur exécution, elle est forcément non XA, elle est optionnelle. Quartz l’utilise avec des transactions JDBC. Dans la documentation Quartz, elle est baptiséenonManagedTXDataSource
, c’est un peut-être choix plus judicieux car il y a bien des transactions!
Certaines implémentations de JTA autorisent l’utilisation d’une DataSource XA en dehors de toute transaction, on peut alors faire pointer dataSource
et nonTransactionalDataSource
sur une même DataSource XA.
Clustering
Afin de garantir la haute-disponibilité de notre solution, mais aussi permettre la scalabilité horizontale, on souhaite pouvoir distribuer le scheduler. Si on instancie naïvement le scheduler sur chacun des nœuds du cluster, chacun d’eux va exécuter au même instant le même Job. Or ce n’est pas notre besoin, nous voulons qu’un (et un seul) nœud du cluster prenne en charge un Job donné.
La solution réside ici aussi dans le JobStore décrit ci-dessus, qui permet à un scheduler donné de poser un verrou sur un Job au moment de l’exécuter, et signaler ainsi aux autre membres du cluster qu’il se charge du traitement. Il faut juste configurer Quartz pour lui signaler qu’il s’agit d’un cluster:
<bean id="quartzScheduler" class="org.springframework.scheduling.quartz.SchedulerFactoryBean"> <property name="dataSource" ref="dataSource"/><!-- JDBC JobStore --> <property name="schedulerName" value="ZenQuartz"/> <property name="quartzProperties"><props> <prop key="org.quartz.scheduler.instanceId">AUTO</prop> <prop key="org.quartz.jobStore.isClustered">true</prop> ... </props></property> </bean>
La propriété org.quartz.scheduler.instanceId=AUTO
permet d’affecter un identifiant unique à chacune des instances de scheduler pour qu’elles puissent s’identifier et se différencier au sein du cluster. Si un noeud tombe, les autres prennent la relève pour l’exécution des Jobs, rien d’extraordinaire étant donné que leur définition est partagée via la base de données.
Évidemment, cette solution ne peut fonctionner que si tous les serveurs sont synchronisés sur une horloge commune (NTP) et pointent vers une même base de données (JDBC JobStore).
La planification des Jobs de manière déclarative depuis la configuration Spring, c’est à dire en utilisant la propriété triggers
, n’est plus possible. En effet, lorsque chacun des nœuds va démarrer, Spring va créer le scheduler et planifier
les Jobs. Si plusieurs nœuds démarrent en parallèle, des erreurs ObjectAlreadyExistsException vont survenir pour signaler la duplication des Jobs/Triggers. Une solution est de faire la planification soi-même de manière impérative pour traiter ce cas d’erreur, et éventuellement utiliser QuartzScheduler#scheduleJobs avec le paramètre replace
.
Threads
Le scheduler Quartz utilise un thread pour lui, plus N threads pour l’exécution des Jobs. Configurer le nom des threads et la taille du pool de threads de Jobs est un must
<bean id="quartzTaskExecutor" class="org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor"> <property name="corePoolSize" value="4"/> <property name="maxPoolSize" value="5"/> <property name="waitForTasksToCompleteOnShutdown" value="true"/> <property name="threadGroupName" value="ZenQuartzJobs"/> <property name="threadNamePrefix" value="ZenQuartzJob" /> </bean> <bean id="quartzScheduler" class="org.springframework.scheduling.quartz.SchedulerFactoryBean"> <property name="taskExecutor" ref="quartzTaskExecutor"/> <property name="quartzProperties"><props> <prop key="org.quartz.scheduler.threadName">ZenQuartzScheduler</prop> </props></property> </bean>
quartzTaskExecutor
est un pool de threads pour les Jobs tandis que org.quartz.scheduler.threadName
correspond au thread principal de Quartz.
Monitoring
Une fois le scheduler en production, le surveiller voire le piloter est un besoin simple mais évident.
Deux propriétés suffisent à exposer Quartz via JMX:
<bean id="quartzScheduler" class="org.springframework.scheduling.quartz.SchedulerFactoryBean"> <property name="quartzProperties"><props> <prop key="org.quartz.scheduler.jmx.export">true</prop> <prop key="org.quartz.scheduler.jmx.objectName">com.zenika.flight:name=QuartzScheduler</prop> </props></property> </bean>
La console JMX affiche l’état et la configuration du scheduler, les Jobs et les Triggers. Des opérations permettent de mettre en pause ou de supprimer des Jobs et des Triggers.
Pour ceux qui préfère le confort d’une console Web, il en existe JWatch et MySchedule en open-source, et QuartzDesk en commercial.
Conclusion
Quartz est un outil puissant et très configurable, cette richesse se fait parfois un peu au détriment de la clarté. Pour les besoins simples, le scheduler Spring suffit: une annotation @Scheduled
et le tour est joué. Pour les besoins plus élaborés (persistance, clustering, monitoring, etc.), Quartz devient nécessaire.
Quel dommage d’avoir collé tant d’XML. Quartz n’a pas besoin de ça, ni de spring pour fonctionner.
J’ai remarqué qu’en mode clusterisé, la configuration de la propriété “jobDetail” telle qu’indiquée ne marche pas.
L’utilisation de <org.springframework.scheduling.quartz.MethodInvokingJobDetailFactoryBean> ne fonctionne pas car le bean n’est pas serializable.
Extrait de la Javadoc de “MethodInvokingJobDetailFactoryBean”:
Il faut passer par <org.springframework.scheduling.quartz.JobDetailFactoryBean>, et donc opérer quelques changements mineurs au niveau de l’implémentation. A savoir, passer par un <org.springframework.scheduling.quartz.QuartzJobBean> et en quelque sorte gérer soi-même les propriétés “targetObject” et “targetMethod” en appelant dans du code Java l’exécution du service.
Un exemple d’implémentation et d’explication sur la problématique en mode clusterisé peut se trouver ici :
http://www.objectpartners.com/2013/…
http://gravspace.wordpress.com/2011…
Bien vu pour l’identification des différentes transactions à prendre en compte dans la mise en place de quartz. Je me demandais cependant pourquoi il était nécessaire de séparer la transaction nécessaire à l’exécution d’un job et celle qui va être utilisée par Quartz pour mettre à jours ses tables et indiquer que le job s’est effectivement exécuté. Ne serait-il pas préferrable au contraire que la mise à jour du job quartz et son exécution ait lieu dans une seule et même transaction, évitant ainsi le risque d’envoyer plusieurs fois des rappels à l’utilisateur?