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() {}
+}

Reply via email to