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()
