This is an automated email from the ASF dual-hosted git repository.
jdaugherty pushed a commit to branch 8.0.x
in repository https://gitbox.apache.org/repos/asf/grails-core.git
The following commit(s) were added to refs/heads/8.0.x by this push:
new 2893699c9f quartz - Jobs now carry the name of the application that
registered them, and the cleanup sweep only considers those.
2893699c9f is described below
commit 2893699c9fd11c726d909a308990e2dcb491b8eb
Author: James Daugherty <[email protected]>
AuthorDate: Sun Aug 23 00:19:58 2026 -0400
quartz - Jobs now carry the name of the application that registered them,
and the cleanup sweep only considers those.
---
.../backgroundJobsAdvanced/quartzClustering.adoc | 1 +
.../quartzConfiguration.adoc | 21 ++
grails-doc/src/en/guide/introduction/whatsNew.adoc | 20 +-
.../src/en/guide/upgrading/upgrading80x.adoc | 62 ++++++
grails-quartz/build.gradle | 2 +-
.../plugins/quartz/JobDetailFactoryBean.java | 29 ++-
.../main/groovy/quartz/QuartzGrailsPlugin.groovy | 46 +++-
.../plugins/quartz/JobDetailFactoryBeanSpec.groovy | 25 +++
.../groovy/quartz/QuartzGrailsPluginSpec.groovy | 34 +++
.../groovy/quartz/QuartzJobStoreCleanupSpec.groovy | 242 +++++++++++++++++++++
10 files changed, 475 insertions(+), 7 deletions(-)
diff --git
a/grails-doc/src/en/guide/backgroundJobs/backgroundJobsAdvanced/quartzClustering.adoc
b/grails-doc/src/en/guide/backgroundJobs/backgroundJobsAdvanced/quartzClustering.adoc
index 053d72e534..56a6a78498 100644
---
a/grails-doc/src/en/guide/backgroundJobs/backgroundJobsAdvanced/quartzClustering.adoc
+++
b/grails-doc/src/en/guide/backgroundJobs/backgroundJobsAdvanced/quartzClustering.adoc
@@ -71,6 +71,7 @@ Every instance must use the same `instanceName` and the same
tables. Only `insta
* *Set `requestsRecovery` on jobs that must not be lost.* Without it, work
that was executing when a node died is not re-run after failover. See
<<quartzJobProperties,Job Properties>>.
* *Jobs must be idempotent anyway.* Clustering guarantees one node fires a
trigger, not that the work is never repeated — a node can die mid-execution
and, with `requestsRecovery`, the job runs again from the start.
* *Clustering does not spread a single execution across nodes.* One firing
runs entirely on one node. Load balancing happens across separate firings, not
within one.
+* *Every application sharing the tables needs its own name.* Each instance
removes the jobs of *its* application which are no longer declared, and
recognises them by the application name — so instances of one application share
a name, while two different applications on the same tables must not. See
<<_jobs_in_a_shared_job_store,The application only removes its own jobs>>.
====
===== If you are not clustering
diff --git
a/grails-doc/src/en/guide/backgroundJobs/backgroundJobsAdvanced/quartzConfiguration.adoc
b/grails-doc/src/en/guide/backgroundJobs/backgroundJobsAdvanced/quartzConfiguration.adoc
index 445ffb7fd3..47e0bfdfed 100644
---
a/grails-doc/src/en/guide/backgroundJobs/backgroundJobsAdvanced/quartzConfiguration.adoc
+++
b/grails-doc/src/en/guide/backgroundJobs/backgroundJobsAdvanced/quartzConfiguration.adoc
@@ -185,6 +185,27 @@ Two things are required for this to work:
Persistent storage interacts with the `durability` and `requestsRecovery` job
properties described in <<quartzJobProperties,Job Properties>>.
+[[_jobs_in_a_shared_job_store]]
+====== The application only removes its own jobs
+
+When an application starts, the plugin removes the jobs it finds in the store
which are no longer enabled
+job artefacts of that application — a job class that has been deleted, or one
turned off with
+`jobEnabled = false`. Every job it registers is stamped with the name of the
application, taken from
+`info.app.name` or from the application metadata when that is unset, and only
jobs carrying that name are
+candidates for removal. A job another application registered, and a job
scheduled through the Quartz API
+rather than declared as a job artefact, are left where they are.
+
+That matters as soon as a job store is shared — between the instances of a
clustered application, or
+between two applications pointing at the same Quartz tables:
+
+* Two applications sharing a store must not share an application name, or each
treats the other's jobs as
+its own and removes them on startup. An application which does not name itself
uses the default name,
+which every unnamed application shares, and the plugin warns about that when
it starts against a JDBC
+job store.
+* A job written to the store by a version of the plugin older than Grails 8
carries no application name,
+so it is never removed automatically. Remove such a leftover through
+<<quartzJobManagerService,jobManagerService>> or from the Quartz tables.
+
[[_trigger_names_and_persistent_storage]]
====== Trigger names are permanent
diff --git a/grails-doc/src/en/guide/introduction/whatsNew.adoc
b/grails-doc/src/en/guide/introduction/whatsNew.adoc
index 97f48232d8..6ab719c72d 100644
--- a/grails-doc/src/en/guide/introduction/whatsNew.adoc
+++ b/grails-doc/src/en/guide/introduction/whatsNew.adoc
@@ -365,7 +365,25 @@ Plugin message bundles must now be namespaced on the
plugin name — `spring-sec
rather than `messages.properties` — so that two plugins cannot shadow one
another. See
<<upgrading80x>> for the details.
-=== Compiled Tag Resolution
+==== Quartz Scheduling Survives a Bad Schedule
+
+The Quartz plugin no longer lets one broken schedule take an application with
it. A trigger that can
+never fire — a cron expression whose last occurrence has passed, an end time
+already gone by — is reported in the log and left unscheduled instead of
stopping startup, and
+`quartz.failOnNeverFiringTriggers` restores the old behaviour for anyone who
wants a bad schedule to fail
+a deployment.
+
+Jobs are also stamped with the application that registered them, so an
application starting up removes
+only its own jobs from the job store. Two applications, or a clustered
application and code scheduling
+through the Quartz API, can now share Quartz tables without one deleting the
other's jobs and triggers.
+
+The scheduling methods a job class gets say what is wrong when they are
misused: a null repeat interval,
+cron expression, date or trigger names the argument that is missing, and
calling them on a job the
+scheduler does not know about explains why it is not registered — neither ends
in a `NullPointerException`
+any more. See <<quartzLongRunningJobs,Long-Running Jobs>> for what a job that
outlives its trigger's
+interval needs, and <<upgrading80x>> for the details.
+
+==== Compiled Tag Resolution
Tag libraries are now described when they are compiled, and that description
resolves tag calls in
pages, tag libraries and controllers compiled afterwards. In a tag library or
a controller, a call
diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
index 49974c05b6..fd3bff35dd 100644
--- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
+++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
@@ -2855,3 +2855,65 @@ class ReportController {
A tag called *with* its namespace, and any tag called from a GSP, already
captured, so only an
unqualified call from a controller to a method-declared tag changes.
+==== 51. Quartz Job Scheduling Behavior Changes
+
+Three behaviors of the Quartz plugin changed in this release. All three
concern what happens when
+something about a schedule is wrong; none of them require a change to a job
class.
+
+===== 51.1 A trigger that can never fire no longer stops startup
+
+A job whose trigger has no possible fire time — a cron expression whose last
occurrence is in the past,
+or an end time that has already passed — used to make the application fail to
start, because the
+scheduler rejects such a trigger:
+
+----
+org.quartz.SchedulerException: Based on configured schedule, the given trigger
will never fire.
+----
+
+The plugin now reports the trigger as an error in the log and leaves it
unscheduled, so the rest of the
+application's jobs and triggers are scheduled and the application starts. The
job itself is still
+registered, so it can be triggered and rescheduled at runtime.
+
+To go back to a failing startup, set `quartz.failOnNeverFiringTriggers`:
+
+[source,yaml]
+.application.yml
+----
+quartz:
+ failOnNeverFiringTriggers: true
+----
+
+===== 51.2 An application only removes the jobs it registered itself
+
+When an application starts, the plugin removes the jobs in the job store which
are no longer enabled job
+artefacts of the application. It used to consider *every* job in the store,
which with a persistent job
+store deleted the jobs and triggers of anything else using it — another
application, or code that
+schedules through the Quartz API.
+
+Every job the plugin registers now carries the name of the application that
registered it, taken from
+`info.app.name` or from the application metadata when that is unset, and only
jobs carrying that name are
+removed. Two consequences for an application with `quartz.jdbcStore` enabled:
+
+* **Two applications sharing a job store must not share an application name.**
Instances of one
+application do share it — that is what makes a clustered deployment clean up
after itself — but two
+different applications on the same Quartz tables each need their own
`info.app.name`. An application which
+does not name itself registers its jobs under the default name, which every
unnamed application shares;
+with `quartz.jdbcStore` enabled the plugin warns about that at startup.
+* **A job written to the store by Grails 7 or earlier carries no application
name**, so it is never
+removed automatically. A job class you deleted while running an older version
leaves a row behind; remove
+it with `jobManagerService.removeJob(group, name)` or from the Quartz tables.
+
+===== 51.3 Scheduling methods report what is wrong instead of failing with a
null pointer
+
+The static scheduling methods every job class gets — `schedule`, `reschedule`,
`unschedule`,
+`triggerNow`, `removeJob` — used to fail with a `NullPointerException` when
called with a null argument,
+or on a job the scheduler does not know about. They now raise:
+
+* an `IllegalArgumentException` naming the argument that is null. Note that a
null argument resolves to
+`schedule(Trigger)` whichever method the caller meant, because the runtime
type of `null` carries no
+information, so the message points at the other arguments too.
+* an `IllegalStateException` when the job is not registered with a scheduler —
the plugin is disabled, the
+job is disabled with `jobEnabled = false`, or the class is not a job artefact
of the application.
+
+An application that caught `NullPointerException` around these calls has to
catch those instead. See
+<<quartzDynamicScheduling,Dynamic Job Scheduling>>.
diff --git a/grails-quartz/build.gradle b/grails-quartz/build.gradle
index 9dd79ebb35..1d7345cbd1 100644
--- a/grails-quartz/build.gradle
+++ b/grails-quartz/build.gradle
@@ -64,7 +64,7 @@ dependencies {
// api: ArtefactHandlerAdapter, GrailsApplication, GrailsClass,
PersistenceContextInterceptor, Plugin
// impl: AbstractGrailsClass, GrailsASTUtils, GrailsClassUtils,
GrailsUtil, TraitInjector
}
- compileOnly 'org.apache.grails.gradle:grails-gradle-model' //
GrailsResourceUtils
+ compileOnly 'org.apache.grails.gradle:grails-gradle-model' //
GrailsResourceUtils, Metadata
compileOnly 'org.apache.groovy:groovy' // Provided as this is a Grails
plugin
compileOnly 'org.apache.groovy:groovy-sql' // groovy.sql.Sql (JdbcCleanup)
compileOnly 'jakarta.annotation:jakarta.annotation-api' // @PostConstruct
diff --git
a/grails-quartz/src/main/groovy/grails/plugins/quartz/JobDetailFactoryBean.java
b/grails-quartz/src/main/groovy/grails/plugins/quartz/JobDetailFactoryBean.java
index b44f621ded..9e41dabe3c 100644
---
a/grails-quartz/src/main/groovy/grails/plugins/quartz/JobDetailFactoryBean.java
+++
b/grails-quartz/src/main/groovy/grails/plugins/quartz/JobDetailFactoryBean.java
@@ -16,6 +16,7 @@
package grails.plugins.quartz;
+import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.springframework.beans.factory.FactoryBean;
@@ -34,8 +35,15 @@ import static org.quartz.JobBuilder.newJob;
public class JobDetailFactoryBean implements FactoryBean<JobDetail>,
InitializingBean {
public static final transient String JOB_NAME_PARAMETER =
"org.grails.plugins.quartz.grailsJobName";
+ /**
+ * The job data entry naming the application a job was registered by. It
is what tells the jobs of one
+ * application apart from the jobs of another when both share a job store.
+ */
+ public static final transient String APPLICATION_NAME_PARAMETER =
"org.apache.grails.quartz.applicationName";
+
// Properties
private GrailsJobClass jobClass;
+ private String applicationName;
// Returned object
private JobDetail jobDetail;
@@ -44,6 +52,16 @@ public class JobDetailFactoryBean implements
FactoryBean<JobDetail>, Initializin
this.jobClass = jobClass;
}
+ /**
+ * Sets the name of the application the job belongs to. The job carries it
as job data, so that the
+ * application can recognise its own jobs in a job store it shares with
other applications.
+ *
+ * @param applicationName the name of the application registering the job
+ */
+ public void setApplicationName(String applicationName) {
+ this.applicationName = applicationName;
+ }
+
/**
* {@inheritDoc}
*
@@ -65,14 +83,19 @@ public class JobDetailFactoryBean implements
FactoryBean<JobDetail>, Initializin
jobClass.isConcurrent() ? GrailsJobFactory.GrailsJob.class :
GrailsJobFactory.StatefulGrailsJob.class;
// Build JobDetail instance.
- jobDetail =
+ JobBuilder builder =
newJob(clazz)
.withIdentity(name, group)
.storeDurably(jobClass.isDurability())
.requestRecovery(jobClass.isRequestsRecovery())
.usingJobData(JOB_NAME_PARAMETER, name)
- .withDescription(jobClass.getDescription())
- .build();
+ .withDescription(jobClass.getDescription());
+
+ if (applicationName != null) {
+ builder.usingJobData(APPLICATION_NAME_PARAMETER, applicationName);
+ }
+
+ jobDetail = builder.build();
}
/**
diff --git a/grails-quartz/src/main/groovy/quartz/QuartzGrailsPlugin.groovy
b/grails-quartz/src/main/groovy/quartz/QuartzGrailsPlugin.groovy
index 7ba21ae7d7..946ef83972 100644
--- a/grails-quartz/src/main/groovy/quartz/QuartzGrailsPlugin.groovy
+++ b/grails-quartz/src/main/groovy/quartz/QuartzGrailsPlugin.groovy
@@ -24,6 +24,7 @@ import grails.plugins.quartz.JobDetailFactoryBean
import grails.plugins.quartz.cleanup.JdbcCleanup
import grails.plugins.quartz.listeners.ExceptionPrinterJobListener
import grails.plugins.quartz.listeners.SessionBinderJobListener
+import grails.util.Metadata
import groovy.util.logging.Slf4j
import org.quartz.JobDetail
import org.quartz.JobKey
@@ -125,6 +126,26 @@ class QuartzGrailsPlugin extends Plugin {
config.getProperty('quartz.failOnNeverFiringTriggers', Boolean, false)
}
+ /**
+ * The name of the application, which every job registered by the plugin
is stamped with. It is what
+ * lets the plugin recognise its own jobs in a job store shared with other
applications, so that it
+ * never removes a job it did not register itself.
+ * @return the value of {@code info.app.name}, or the name the application
metadata reports
+ */
+ String getApplicationName() {
+ config.getProperty('info.app.name', String) ?:
grailsApplication.metadata.getApplicationName()
+ }
+
+ /**
+ * Whether the application registers its jobs under a name of its own. An
application which does not
+ * name itself registers them under the default name, which every
application that does not name
+ * itself shares.
+ * @return {@code false} when the name of the application is the default
one
+ */
+ boolean isApplicationNamed() {
+ getApplicationName() != Metadata.DEFAULT_APPLICATION_NAME
+ }
+
/**
* The name given to the scheduler. When unset the bean name is used.
* @return the value of {@code quartz.scheduler.instanceName}, or {@code
null}
@@ -283,6 +304,7 @@ class QuartzGrailsPlugin extends Plugin {
// Creates job details
JobDetailFactoryBean jdfb = new JobDetailFactoryBean()
jdfb.jobClass = jobClass
+ jdfb.applicationName = getApplicationName()
jdfb.afterPropertiesSet()
JobDetail jobDetail = jdfb.object
@@ -333,6 +355,15 @@ class QuartzGrailsPlugin extends Plugin {
}
}
+ /**
+ * Whether the job was registered by this application, which is the case
when it carries the name of
+ * the application as job data. A job of another application, and a job
registered through the Quartz
+ * API rather than as a job artefact, does not.
+ */
+ private boolean isJobOfThisApplication(JobDetail jobDetail) {
+
jobDetail?.jobDataMap?.get(JobDetailFactoryBean.APPLICATION_NAME_PARAMETER) ==
getApplicationName()
+ }
+
/**
* Whether the trigger can fire at least once, which is what the scheduler
demands of a trigger before
* it accepts it. The first fire time is computed the way the scheduler
computes it, from the second
@@ -361,11 +392,17 @@ class QuartzGrailsPlugin extends Plugin {
Set<JobKey> jobKeys =
quartzScheduler.getJobKeys(GroupMatcher.anyGroup())
- //Remove any recently removed / disabled Jobs
+ //Remove the jobs of this application which are no longer active. Jobs
another application
+ //registered, and jobs registered through the Quartz API rather than
as job artefacts, are left
+ //alone: a job store can be shared, and the plugin only owns what it
registered itself.
jobKeys.each { JobKey key ->
+ if (!isJobOfThisApplication(quartzScheduler.getJobDetail(key))) {
+ log.debug("Leaving job ${key} alone: it was not registered by
this application")
+ return
+ }
def match = grailsApplication.jobClasses.find { GrailsJobClass
jobClass -> jobClass.isEnabled() && jobClass.group == key.group &&
jobClass.clazz.name == key.name }
if (!match) {
- log.info("Removing No longer Active Job: ${key.name}")
+ log.info("Removing job ${key}, which is no longer an enabled
job artefact of this application")
def triggersForJob =
quartzScheduler.getTriggersOfJob(key)?.collect { it.key }
if (triggersForJob) {
//clean up triggers before we remove the job
@@ -394,6 +431,11 @@ class QuartzGrailsPlugin extends Plugin {
void onStartup(Map<String, Object> event) {
if (isPluginEnabled()) {
+ if (isJdbcStore() && !isApplicationNamed()) {
+ log.warn('The application does not name itself, so it
registers its jobs under the default ' +
+ "name [${Metadata.DEFAULT_APPLICATION_NAME}]. Another
application which shares both " +
+ 'the job store and that name removes them when it
starts: set info.app.name.')
+ }
refreshJobs()
if (isAutoStartup()) {
applicationContext.quartzScheduler.start()
diff --git
a/grails-quartz/src/test/groovy/grails/plugins/quartz/JobDetailFactoryBeanSpec.groovy
b/grails-quartz/src/test/groovy/grails/plugins/quartz/JobDetailFactoryBeanSpec.groovy
index 71a396f5fc..4c2f0f7f10 100644
---
a/grails-quartz/src/test/groovy/grails/plugins/quartz/JobDetailFactoryBeanSpec.groovy
+++
b/grails-quartz/src/test/groovy/grails/plugins/quartz/JobDetailFactoryBeanSpec.groovy
@@ -88,6 +88,31 @@ class JobDetailFactoryBeanSpec extends Specification {
!jobDetail.requestsRecovery()
jobDetail.description == null
}
+
+ void 'the job carries the name of the application it was registered by'() {
+ setup:
+ factory.jobClass = new GrailsJobClassMock(fullName: JOB_NAME,
group: JOB_GROUP, concurrent: true)
+ factory.applicationName = 'reporting'
+ factory.afterPropertiesSet()
+
+ when:
+ JobDetail jobDetail = factory.object
+
+ then:
+
jobDetail.jobDataMap.get(JobDetailFactoryBean.APPLICATION_NAME_PARAMETER) ==
'reporting'
+ }
+
+ void 'a job registered without an application name carries none'() {
+ setup:
+ factory.jobClass = new GrailsJobClassMock(fullName: JOB_NAME,
group: JOB_GROUP, concurrent: true)
+ factory.afterPropertiesSet()
+
+ when:
+ JobDetail jobDetail = factory.object
+
+ then:
+
!jobDetail.jobDataMap.containsKey(JobDetailFactoryBean.APPLICATION_NAME_PARAMETER)
+ }
}
class GrailsJobClassMock implements GrailsJobClass {
diff --git a/grails-quartz/src/test/groovy/quartz/QuartzGrailsPluginSpec.groovy
b/grails-quartz/src/test/groovy/quartz/QuartzGrailsPluginSpec.groovy
index 86c8027c97..3d78e0a307 100644
--- a/grails-quartz/src/test/groovy/quartz/QuartzGrailsPluginSpec.groovy
+++ b/grails-quartz/src/test/groovy/quartz/QuartzGrailsPluginSpec.groovy
@@ -25,6 +25,7 @@ import grails.plugins.quartz.JobArtefactHandler
import grails.plugins.quartz.listeners.ExceptionPrinterJobListener
import grails.plugins.quartz.listeners.SessionBinderJobListener
import grails.spring.BeanBuilder
+import grails.util.Metadata
import org.grails.config.PropertySourcesConfig
import org.quartz.Scheduler
import org.quartz.impl.matchers.GroupMatcher
@@ -54,6 +55,39 @@ class QuartzGrailsPluginSpec extends Specification {
plugin.getSchedulerInstanceName() == null
}
+ void 'the application name the jobs of the application are stamped with
comes from the configuration'() {
+ given:
+ QuartzGrailsPlugin plugin = pluginFor('info.app.name': 'reporting')
+
+ expect:
+ plugin.getApplicationName() == 'reporting'
+ }
+
+ void 'an application which does not name itself falls back to the name of
its metadata'() {
+ given:
+ QuartzGrailsPlugin plugin = pluginFor([:])
+
+ expect:
+ plugin.getApplicationName() ==
plugin.grailsApplication.metadata.getApplicationName()
+ plugin.getApplicationName()
+ }
+
+ void 'an application which names itself registers its jobs under a name of
its own'() {
+ given:
+ QuartzGrailsPlugin plugin = pluginFor('info.app.name': 'reporting')
+
+ expect:
+ plugin.isApplicationNamed()
+ }
+
+ void 'an application named as every unnamed application is does not count
as named'() {
+ given:
+ QuartzGrailsPlugin plugin = pluginFor('info.app.name':
Metadata.DEFAULT_APPLICATION_NAME)
+
+ expect:
+ !plugin.isApplicationNamed()
+ }
+
void 'a quartz block that does not mention an option leaves that option at
its default'() {
given: 'a configuration which configures quartz, but says nothing
about the job store'
QuartzGrailsPlugin plugin =
pluginFor('quartz.threadPool.threadCount': 10)
diff --git
a/grails-quartz/src/test/groovy/quartz/QuartzJobStoreCleanupSpec.groovy
b/grails-quartz/src/test/groovy/quartz/QuartzJobStoreCleanupSpec.groovy
new file mode 100644
index 0000000000..15c8720ded
--- /dev/null
+++ b/grails-quartz/src/test/groovy/quartz/QuartzJobStoreCleanupSpec.groovy
@@ -0,0 +1,242 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package quartz
+
+import grails.artefact.Artefact
+import grails.core.DefaultGrailsApplication
+import grails.core.GrailsApplication
+import grails.plugins.GrailsPluginManager
+import grails.plugins.quartz.DefaultGrailsJobClass
+import grails.plugins.quartz.JobArtefactHandler
+import grails.plugins.quartz.JobDetailFactoryBean
+import grails.plugins.quartz.TestQuartzJob
+import org.grails.config.PropertySourcesConfig
+import org.quartz.JobBuilder
+import org.quartz.JobDetail
+import org.quartz.JobKey
+import org.quartz.Scheduler
+import org.quartz.Trigger
+import org.quartz.TriggerBuilder
+import org.quartz.TriggerKey
+import org.quartz.impl.StdSchedulerFactory
+import org.springframework.context.support.GenericApplicationContext
+import spock.lang.Specification
+
+/**
+ * Tests which jobs the plugin removes from the job store of a starting
application. A job store can be
+ * shared — with other applications, and with code which schedules jobs
through the Quartz API — so only
+ * the jobs the application registered itself are its to remove.
+ */
+class QuartzJobStoreCleanupSpec extends Specification {
+
+ private static final String JOBS_GROUP = 'GRAILS_JOBS'
+ private static final String TRIGGERS_GROUP = 'GRAILS_TRIGGERS'
+
+ Scheduler scheduler
+
+ void setup() {
+ Properties properties = new Properties()
+ properties.setProperty('org.quartz.scheduler.instanceName',
"scheduler-${System.identityHashCode(this)}" as String)
+ properties.setProperty('org.quartz.threadPool.threadCount', '1')
+ properties.setProperty('org.quartz.job.store.class',
'org.quartz.simpl.RAMJobStore')
+ scheduler = new StdSchedulerFactory(properties).getScheduler()
+ }
+
+ void cleanup() {
+ scheduler.shutdown()
+ }
+
+ void 'a job the application registered is removed once its artefact is
gone'() {
+ given: 'an application which registered two jobs'
+ pluginFor(KeptJob, RemovedJob).onStartup([:])
+
+ expect:
+ scheduler.checkExists(JobKey.jobKey(RemovedJob.name, JOBS_GROUP))
+
+ when: 'it starts again without one of them'
+ pluginFor(KeptJob).onStartup([:])
+
+ then: 'the job it no longer declares is removed, along with its
triggers'
+ !scheduler.checkExists(JobKey.jobKey(RemovedJob.name, JOBS_GROUP))
+ !scheduler.checkExists(TriggerKey.triggerKey('removed',
TRIGGERS_GROUP))
+
+ and: 'the job it still declares is left scheduled'
+ scheduler.checkExists(JobKey.jobKey(KeptJob.name, JOBS_GROUP))
+ scheduler.checkExists(TriggerKey.triggerKey('kept',
TRIGGERS_GROUP))
+ }
+
+ void 'a job the application registered is removed once its artefact is
disabled'() {
+ given: 'a job which was registered while it was still enabled'
+ QuartzGrailsPlugin plugin = pluginFor(DisabledArtefactJob)
+ scheduler.addJob(jobDetailFor(DisabledArtefactJob,
plugin.applicationName), true)
+
+ expect:
+ scheduler.checkExists(JobKey.jobKey(DisabledArtefactJob.name,
JOBS_GROUP))
+
+ when:
+ plugin.onStartup([:])
+
+ then:
+ !scheduler.checkExists(JobKey.jobKey(DisabledArtefactJob.name,
JOBS_GROUP))
+ }
+
+ void 'the jobs of another application sharing the job store are left
alone'() {
+ given: 'an application which registered a job of its own in the shared
job store'
+ pluginFor('reporting', RemovedJob).onStartup([:])
+
+ expect:
+ scheduler.checkExists(JobKey.jobKey(RemovedJob.name, JOBS_GROUP))
+
+ when: 'another application, which does not declare that job, starts
against the same store'
+ pluginFor('scheduling', KeptJob).onStartup([:])
+
+ then: 'the job of the first application is still scheduled'
+ scheduler.checkExists(JobKey.jobKey(RemovedJob.name, JOBS_GROUP))
+ scheduler.checkExists(TriggerKey.triggerKey('removed',
TRIGGERS_GROUP))
+
+ and: 'the second application registered its own job'
+ scheduler.checkExists(JobKey.jobKey(KeptJob.name, JOBS_GROUP))
+ }
+
+ void 'an application only removes the jobs it registered itself when it
starts again'() {
+ given: 'two applications which each registered a job in the shared job
store'
+ pluginFor('reporting', RemovedJob).onStartup([:])
+ pluginFor('scheduling', KeptJob, DroppedJob).onStartup([:])
+
+ when: 'the second application starts again without one of its jobs'
+ pluginFor('scheduling', KeptJob).onStartup([:])
+
+ then: 'only its own job is gone'
+ !scheduler.checkExists(JobKey.jobKey(DroppedJob.name, JOBS_GROUP))
+ scheduler.checkExists(JobKey.jobKey(KeptJob.name, JOBS_GROUP))
+ scheduler.checkExists(JobKey.jobKey(RemovedJob.name, JOBS_GROUP))
+ }
+
+ void 'a job registered through the quartz api is left in the job store'() {
+ given: 'a job scheduled without the plugin, in a group of its own'
+ JobDetail native_ = JobBuilder.newJob(TestQuartzJob)
+ .withIdentity('nativeJob', 'nativeJobs')
+ .storeDurably()
+ .build()
+ scheduler.addJob(native_, true)
+ scheduler.scheduleJob(triggerFor(native_, 'nativeTrigger'))
+
+ when:
+ pluginFor(KeptJob).onStartup([:])
+
+ then:
+ scheduler.checkExists(native_.key)
+ scheduler.checkExists(TriggerKey.triggerKey('nativeTrigger',
TRIGGERS_GROUP))
+ }
+
+ void 'the jobs the application registers carry the name of the
application'() {
+ given:
+ QuartzGrailsPlugin plugin = pluginFor(KeptJob)
+
+ when:
+ plugin.onStartup([:])
+
+ then:
+ JobDetail jobDetail =
scheduler.getJobDetail(JobKey.jobKey(KeptJob.name, JOBS_GROUP))
+
jobDetail.jobDataMap.get(JobDetailFactoryBean.APPLICATION_NAME_PARAMETER) ==
plugin.applicationName
+ }
+
+ private JobDetail jobDetailFor(Class jobClass, String applicationName) {
+ JobDetailFactoryBean factory = new JobDetailFactoryBean()
+ factory.jobClass = new DefaultGrailsJobClass(jobClass)
+ factory.applicationName = applicationName
+ factory.afterPropertiesSet()
+ factory.object
+ }
+
+ private Trigger triggerFor(JobDetail jobDetail, String name) {
+ TriggerBuilder.newTrigger()
+ .withIdentity(name, TRIGGERS_GROUP)
+ .forJob(jobDetail)
+ .startAt(new Date(System.currentTimeMillis() + 600_000L))
+ .build()
+ }
+
+ private QuartzGrailsPlugin pluginFor(Class... jobClasses) {
+ pluginFor(null, jobClasses)
+ }
+
+ private QuartzGrailsPlugin pluginFor(String applicationName, Class...
jobClasses) {
+ Map<String, Object> config = ['quartz.autoStartup': false]
+ if (applicationName) {
+ config['info.app.name'] = applicationName
+ }
+
+ GrailsApplication grailsApplication = new DefaultGrailsApplication()
+ grailsApplication.config = new PropertySourcesConfig(config)
+ grailsApplication.registerArtefactHandler(new JobArtefactHandler())
+ grailsApplication.initialise()
+ jobClasses.each {
grailsApplication.addArtefact(JobArtefactHandler.TYPE, it) }
+
+ QuartzGrailsPlugin plugin = new QuartzGrailsPlugin()
+ plugin.grailsApplication = grailsApplication
+ plugin.pluginManager = Stub(GrailsPluginManager) {
+ hasGrailsPlugin(_ as String) >> false
+ }
+ GenericApplicationContext applicationContext = new
GenericApplicationContext()
+ applicationContext.beanFactory.registerSingleton('quartzScheduler',
scheduler)
+ applicationContext.refresh()
+ plugin.applicationContext = applicationContext
+ plugin
+ }
+}
+
+@Artefact('Job')
+class KeptJob {
+
+ static triggers = {
+ simple name: 'kept', startDelay: 600_000L, repeatInterval: 600_000L
+ }
+
+ void execute() {}
+}
+
+@Artefact('Job')
+class RemovedJob {
+
+ static triggers = {
+ simple name: 'removed', startDelay: 600_000L, repeatInterval: 600_000L
+ }
+
+ void execute() {}
+}
+
+@Artefact('Job')
+class DroppedJob {
+
+ static triggers = {
+ simple name: 'dropped', startDelay: 600_000L, repeatInterval: 600_000L
+ }
+
+ void execute() {}
+}
+
+@Artefact('Job')
+class DisabledArtefactJob {
+
+ static jobEnabled = false
+
+ void execute() {}
+}