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

commit 5591c70e9ff033109b6102007544e0e763eea7c1
Author: James Daugherty <[email protected]>
AuthorDate: Sat Aug 22 23:51:19 2026 -0400

    Do not error if a quartz trigger is being scheduled in the past
---
 .../quartzConfiguration.adoc                       |   5 +
 .../backgroundJobsAdvanced/quartzTriggers.adoc     |   5 +
 .../main/groovy/quartz/QuartzGrailsPlugin.groovy   |  27 +++
 .../groovy/quartz/QuartzGrailsPluginSpec.groovy    |   9 +-
 .../quartz/QuartzStartupSchedulingSpec.groovy      | 198 +++++++++++++++++++++
 .../jobs/quartzapp/NeverFiringJob.groovy           |  33 ++++
 .../groovy/quartzapp/QuartzSchedulingSpec.groovy   |  10 ++
 7 files changed, 286 insertions(+), 1 deletion(-)

diff --git 
a/grails-doc/src/en/guide/backgroundJobs/backgroundJobsAdvanced/quartzConfiguration.adoc
 
b/grails-doc/src/en/guide/backgroundJobs/backgroundJobsAdvanced/quartzConfiguration.adoc
index 351712fea6..445ffb7fd3 100644
--- 
a/grails-doc/src/en/guide/backgroundJobs/backgroundJobsAdvanced/quartzConfiguration.adoc
+++ 
b/grails-doc/src/en/guide/backgroundJobs/backgroundJobsAdvanced/quartzConfiguration.adoc
@@ -26,6 +26,7 @@ quartz:
     jdbcStore: false
 ----
 
+[[_plugin_options]]
 ===== Plugin options
 
 [cols="2,1,4"]
@@ -60,6 +61,10 @@ quartz:
 | `false`
 | Whether the scheduler is registered with the Quartz `SchedulerRepository`, 
making it reachable via `StdSchedulerFactory.getDefaultScheduler()`.
 
+| `quartz.failOnNeverFiringTriggers`
+| `false`
+| Whether startup fails when a job declares a trigger which can never fire — a 
cron expression whose last occurrence is in the past, for example. By default 
such a trigger is reported as an error in the log and skipped, and the 
application starts with the rest of its jobs and triggers scheduled. Set this 
to `true` to have the scheduler reject the trigger and the application fail to 
start.
+
 | `quartz.scheduler.instanceName`
 | The bean name
 | The name given to the scheduler. Set this when several applications share a 
job store, so their schedulers do not collide.
diff --git 
a/grails-doc/src/en/guide/backgroundJobs/backgroundJobsAdvanced/quartzTriggers.adoc
 
b/grails-doc/src/en/guide/backgroundJobs/backgroundJobsAdvanced/quartzTriggers.adoc
index b7a5c36ad6..f567d5860d 100644
--- 
a/grails-doc/src/en/guide/backgroundJobs/backgroundJobsAdvanced/quartzTriggers.adoc
+++ 
b/grails-doc/src/en/guide/backgroundJobs/backgroundJobsAdvanced/quartzTriggers.adoc
@@ -158,6 +158,11 @@ class MyJob {
 }
 ----
 
+A trigger that can never fire — a cron expression whose last occurrence is in 
the past, or an end time
+that has already passed — is reported as an error in the log and left 
unscheduled, so it does not keep the
+application from starting. Configure `quartz.failOnNeverFiringTriggers` to 
have startup fail instead; see
+<<_plugin_options,Configuration>>.
+
 The `triggers` closure is given access to the `grailsApplication` object, so 
trigger attributes can be driven from configuration:
 
 [source,groovy]
diff --git a/grails-quartz/src/main/groovy/quartz/QuartzGrailsPlugin.groovy 
b/grails-quartz/src/main/groovy/quartz/QuartzGrailsPlugin.groovy
index fb954151b0..7ba21ae7d7 100644
--- a/grails-quartz/src/main/groovy/quartz/QuartzGrailsPlugin.groovy
+++ b/grails-quartz/src/main/groovy/quartz/QuartzGrailsPlugin.groovy
@@ -115,6 +115,16 @@ class QuartzGrailsPlugin extends Plugin {
         config.getProperty('quartz.exposeSchedulerInRepository', Boolean, 
false)
     }
 
+    /**
+     * Whether startup fails when a job declares a trigger which can never 
fire, for example a cron
+     * expression whose last occurrence is in the past. When {@code false} 
such a trigger is reported
+     * and skipped, and the rest of the application starts as usual.
+     * @return {@code false} unless {@code quartz.failOnNeverFiringTriggers} 
is configured otherwise
+     */
+    boolean isFailOnNeverFiringTriggers() {
+        config.getProperty('quartz.failOnNeverFiringTriggers', Boolean, false)
+    }
+
     /**
      * The name given to the scheduler. When unset the bean name is used.
      * @return the value of {@code quartz.scheduler.instanceName}, or {@code 
null}
@@ -305,6 +315,11 @@ class QuartzGrailsPlugin extends Plugin {
                 Trigger trigger = factory.object
 
                 TriggerKey key = trigger.key
+                if (!isFailOnNeverFiringTriggers() && !willEverFire(trigger)) {
+                    log.error("The trigger ${key} of the job ${fullName} will 
never fire based on its configured " +
+                            'schedule, so it has not been scheduled. Check its 
cron expression, start time and end time.')
+                    return
+                }
                 log.debug("Scheduling $fullName with trigger $key: ${trigger}")
                 if (scheduler.getTrigger(key) != null) {
                     scheduler.rescheduleJob(key, trigger)
@@ -318,6 +333,18 @@ class QuartzGrailsPlugin extends Plugin {
         }
     }
 
+    /**
+     * 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
+     * before the start time. A trigger bound to a Quartz calendar can still 
be rejected by the scheduler,
+     * because the calendar which excludes its fire times lives in the job 
store.
+     */
+    private boolean willEverFire(Trigger trigger) {
+        Date startTime = trigger.startTime
+        // Without a start time the first fire time cannot be computed here, 
so leave the decision to the scheduler.
+        startTime == null || trigger.getFireTimeAfter(new Date(startTime.time 
- 1000L)) != null
+    }
+
     private boolean hasHibernate(manager) {
         manager?.hasGrailsPlugin('hibernate') ||
                 manager?.hasGrailsPlugin('hibernate3') ||
diff --git a/grails-quartz/src/test/groovy/quartz/QuartzGrailsPluginSpec.groovy 
b/grails-quartz/src/test/groovy/quartz/QuartzGrailsPluginSpec.groovy
index 66398e7139..86c8027c97 100644
--- a/grails-quartz/src/test/groovy/quartz/QuartzGrailsPluginSpec.groovy
+++ b/grails-quartz/src/test/groovy/quartz/QuartzGrailsPluginSpec.groovy
@@ -50,6 +50,7 @@ class QuartzGrailsPluginSpec extends Specification {
             !plugin.isPurgeQuartzTablesOnStartup()
             plugin.isWaitForJobsToCompleteOnShutdown()
             !plugin.isExposeSchedulerInRepository()
+            !plugin.isFailOnNeverFiringTriggers()
             plugin.getSchedulerInstanceName() == null
     }
 
@@ -72,6 +73,7 @@ class QuartzGrailsPluginSpec extends Specification {
                     'quartz.purgeQuartzTablesOnStartup': true,
                     'quartz.waitForJobsToCompleteOnShutdown': false,
                     'quartz.exposeSchedulerInRepository': true,
+                    'quartz.failOnNeverFiringTriggers': true,
                     'quartz.scheduler.instanceName': 'reportScheduler')
 
         expect:
@@ -82,16 +84,21 @@ class QuartzGrailsPluginSpec extends Specification {
             plugin.isPurgeQuartzTablesOnStartup()
             !plugin.isWaitForJobsToCompleteOnShutdown()
             plugin.isExposeSchedulerInRepository()
+            plugin.isFailOnNeverFiringTriggers()
             plugin.getSchedulerInstanceName() == 'reportScheduler'
     }
 
     void 'options configured as strings are coerced to booleans'() {
         given:
-            QuartzGrailsPlugin plugin = pluginFor('quartz.pluginEnabled': 
'false', 'quartz.jdbcStore': 'true')
+            QuartzGrailsPlugin plugin = pluginFor(
+                    'quartz.pluginEnabled': 'false',
+                    'quartz.jdbcStore': 'true',
+                    'quartz.failOnNeverFiringTriggers': 'true')
 
         expect:
             !plugin.isPluginEnabled()
             plugin.isJdbcStore()
+            plugin.isFailOnNeverFiringTriggers()
     }
 
     void 'the plugin registers a scheduler, a job factory and an exception 
listener'() {
diff --git 
a/grails-quartz/src/test/groovy/quartz/QuartzStartupSchedulingSpec.groovy 
b/grails-quartz/src/test/groovy/quartz/QuartzStartupSchedulingSpec.groovy
new file mode 100644
index 0000000000..a8529d9748
--- /dev/null
+++ b/grails-quartz/src/test/groovy/quartz/QuartzStartupSchedulingSpec.groovy
@@ -0,0 +1,198 @@
+/*
+ *  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.JobArtefactHandler
+import org.grails.config.PropertySourcesConfig
+import org.quartz.JobKey
+import org.quartz.Scheduler
+import org.quartz.SchedulerException
+import org.quartz.Trigger
+import org.quartz.TriggerKey
+import org.quartz.impl.StdSchedulerFactory
+import org.springframework.context.support.GenericApplicationContext
+import spock.lang.Specification
+
+import java.time.Year
+
+/**
+ * Tests what the plugin does with the triggers of the job artefacts of an 
application while it starts,
+ * against a scheduler holding its jobs in memory.
+ */
+class QuartzStartupSchedulingSpec 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 'the triggers a job declares are scheduled while the application 
starts'() {
+        given:
+            QuartzGrailsPlugin plugin = pluginFor([:], RepeatingJob)
+
+        when:
+            plugin.onStartup([:])
+
+        then:
+            scheduler.checkExists(JobKey.jobKey(RepeatingJob.name, JOBS_GROUP))
+            scheduler.checkExists(TriggerKey.triggerKey('repeating', 
TRIGGERS_GROUP))
+            scheduler.isStarted()
+    }
+
+    void 'a trigger that can never fire does not stop the application from 
starting'() {
+        given: 'a job whose cron expression has its last occurrence in the 
past'
+            QuartzGrailsPlugin plugin = pluginFor([:], PastCronJob)
+
+        when:
+            plugin.onStartup([:])
+
+        then: 'the job is registered, but the trigger the scheduler would 
refuse is left out'
+            noExceptionThrown()
+            scheduler.checkExists(JobKey.jobKey(PastCronJob.name, JOBS_GROUP))
+            scheduler.getTriggersOfJob(JobKey.jobKey(PastCronJob.name, 
JOBS_GROUP)).isEmpty()
+            !scheduler.checkExists(TriggerKey.triggerKey('pastCron', 
TRIGGERS_GROUP))
+    }
+
+    void 'the other triggers of a job with a trigger that can never fire are 
still scheduled'() {
+        given:
+            QuartzGrailsPlugin plugin = pluginFor([:], MixedTriggersJob)
+
+        when:
+            plugin.onStartup([:])
+
+        then:
+            List<Trigger> triggers = 
scheduler.getTriggersOfJob(JobKey.jobKey(MixedTriggersJob.name, JOBS_GROUP))
+            triggers.collect { it.key.name } == ['mixedFiring']
+    }
+
+    void 'the other jobs of an application with a trigger that can never fire 
are still scheduled'() {
+        given:
+            QuartzGrailsPlugin plugin = pluginFor([:], PastCronJob, 
RepeatingJob)
+
+        when:
+            plugin.onStartup([:])
+
+        then:
+            scheduler.checkExists(TriggerKey.triggerKey('repeating', 
TRIGGERS_GROUP))
+            !scheduler.checkExists(TriggerKey.triggerKey('pastCron', 
TRIGGERS_GROUP))
+    }
+
+    void 'a trigger that can never fire stops the application from starting 
when the plugin is configured to fail'() {
+        given:
+            QuartzGrailsPlugin plugin = 
pluginFor(['quartz.failOnNeverFiringTriggers': true], PastCronJob)
+
+        when:
+            plugin.onStartup([:])
+
+        then:
+            SchedulerException e = thrown()
+            e.message.contains('will never fire')
+
+        and: 'the scheduler was never started'
+            !scheduler.isStarted()
+    }
+
+    void 'a trigger that fires only once in the future is scheduled'() {
+        given:
+            QuartzGrailsPlugin plugin = pluginFor([:], FutureCronJob)
+
+        when:
+            plugin.onStartup([:])
+
+        then:
+            scheduler.checkExists(TriggerKey.triggerKey('futureCron', 
TRIGGERS_GROUP))
+    }
+
+    private QuartzGrailsPlugin pluginFor(Map<String, Object> config, Class... 
jobClasses) {
+        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 RepeatingJob {
+
+    static triggers = {
+        simple name: 'repeating', startDelay: 0L, repeatInterval: 60_000L
+    }
+
+    void execute() {}
+}
+
+@Artefact('Job')
+class PastCronJob {
+
+    static triggers = {
+        cron name: 'pastCron', cronExpression: '0 0 12 1 1 ? 2020'
+    }
+
+    void execute() {}
+}
+
+@Artefact('Job')
+class FutureCronJob {
+
+    static triggers = {
+        cron name: 'futureCron', cronExpression: "0 0 12 1 1 ? 
${Year.now().value + 5}"
+    }
+
+    void execute() {}
+}
+
+@Artefact('Job')
+class MixedTriggersJob {
+
+    static triggers = {
+        cron name: 'mixedPast', cronExpression: '0 0 12 1 1 ? 2020'
+        simple name: 'mixedFiring', startDelay: 0L, repeatInterval: 60_000L
+    }
+
+    void execute() {}
+}
diff --git 
a/grails-test-examples/quartz/grails-app/jobs/quartzapp/NeverFiringJob.groovy 
b/grails-test-examples/quartz/grails-app/jobs/quartzapp/NeverFiringJob.groovy
new file mode 100644
index 0000000000..d7e7520455
--- /dev/null
+++ 
b/grails-test-examples/quartz/grails-app/jobs/quartzapp/NeverFiringJob.groovy
@@ -0,0 +1,33 @@
+/*
+ *  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 quartzapp
+
+/**
+ * A job whose cron expression had its last occurrence in the past, so that 
the scheduler would refuse
+ * the trigger. The application has to start regardless of it.
+ */
+class NeverFiringJob {
+
+    static triggers = {
+        cron name: 'neverFiring', cronExpression: '0 0 12 1 1 ? 2020'
+    }
+
+    void execute() {}
+}
diff --git 
a/grails-test-examples/quartz/src/integration-test/groovy/quartzapp/QuartzSchedulingSpec.groovy
 
b/grails-test-examples/quartz/src/integration-test/groovy/quartzapp/QuartzSchedulingSpec.groovy
index b8c39ed3f1..a9a3ebc64f 100644
--- 
a/grails-test-examples/quartz/src/integration-test/groovy/quartzapp/QuartzSchedulingSpec.groovy
+++ 
b/grails-test-examples/quartz/src/integration-test/groovy/quartzapp/QuartzSchedulingSpec.groovy
@@ -120,6 +120,16 @@ class QuartzSchedulingSpec extends Specification {
             e.message.contains('resolves to the one taking a trigger')
     }
 
+    void 'a job whose trigger can never fire does not keep the application 
from starting'() {
+        given:
+            JobKey jobKey = JobKey.jobKey(NeverFiringJob.name, 'GRAILS_JOBS')
+
+        expect: 'the application is up, with the job registered but without 
the trigger it declared'
+            quartzScheduler.isStarted()
+            quartzScheduler.checkExists(jobKey)
+            quartzScheduler.getTriggersOfJob(jobKey).isEmpty()
+    }
+
     void 'scheduling a job that the scheduler does not know about reports 
why'() {
         when: 'a job that is turned off is scheduled at runtime'
             DisabledJob.triggerNow()

Reply via email to