package myapp
import grails.boot.GrailsApp
import grails.boot.config.GrailsAutoConfiguration
import org.springframework.scheduling.annotation.EnableScheduling
@EnableScheduling
class Application extends GrailsAutoConfiguration {
static void main(String[] args) {
GrailsApp.run(Application, args)
}
}
20 Background Jobs
Version: 8.0.0-SNAPSHOT
Table of Contents
20 Background Jobs
Most applications eventually need work to happen on a schedule rather than in response to a request: sending a nightly digest, expiring stale records, polling an upstream feed, rebuilding a cache.
Grails offers two approaches:
-
Basic Scheduling uses Spring’s
@Scheduledannotation. It is built into the framework, needs no dependencies, and is the right default for the large majority of applications. -
Advanced Scheduling uses the Quartz plugin, which adds runtime-modifiable schedules, persistence, operational control, and — once configured for it — clustering across a multi-instance deployment.
Start with Basic Scheduling. Spring’s scheduler is simpler, has nothing to configure, and covers everything a single-instance application with fixed schedules needs. Reach for Quartz when you have a requirement Spring’s scheduler cannot meet.
When you need Quartz
Quartz earns its extra moving parts in these situations:
-
Multiple instances. This is the big one.
@Scheduledfires on every instance independently, so a nightly job on a three-instance deployment runs three times. Quartz can be configured to cluster, so exactly one instance fires each trigger. -
Schedules that change at runtime, driven by user input or data rather than fixed in an annotation.
-
Schedules that must survive a restart, rather than being rebuilt from source at every startup.
-
Operational control — pausing, resuming, inspecting, or interrupting jobs while the application runs.
-
Recovery of work lost to a crash, re-running jobs that were executing when an instance died.
If none of those apply, @Scheduled is the better choice; adding Quartz only adds moving parts to get wrong.
|
Adding the Quartz plugin does not by itself make a schedule cluster-safe. Quartz defaults to an in-memory job store, which duplicates work across instances exactly as |
Comparison
| Basic Scheduling | Advanced Scheduling | |
|---|---|---|
Mechanism |
Spring |
Quartz plugin |
Setup |
Built in — nothing to add |
Add |
Where the schedule lives |
An annotation on a bean method |
A |
Changing the schedule at runtime |
Not supported |
Supported |
Persistent across restarts |
No |
Optional, with a JDBC job store |
Safe to run on multiple instances |
No — every instance fires independently |
Only when clustering is configured; not by default |
Pausing, resuming, inspecting jobs |
Not supported |
Supported |
| The most common production surprise with background jobs is a schedule written when the application ran on one machine, later deployed to three. Nothing fails loudly; the job simply runs several times over. Rolling restarts and blue/green deployments trigger this too, because two instances are briefly live at once even in a nominally single-instance deployment. See Clustering. |
20.1 Basic Scheduling
Spring’s scheduling support is part of the framework, so it is available in every Grails application without adding a dependency.
Enabling scheduling
Annotate your Application class with @EnableScheduling:
Grails 3 enabled scheduling implicitly. From Grails 4 onwards @EnableScheduling must be applied explicitly — see Upgrading from Grails 3.3.x to Grails 4.
|
Scheduling a method
Annotate any Spring bean method — a service is the natural home — with @Scheduled:
package myapp
import org.springframework.scheduling.annotation.Scheduled
class ReportService {
@Scheduled(cron = '0 0 6 * * ?')
void sendDailyDigest() {
// ...
}
}
A @Scheduled method must take no arguments and return void. Spring 6.1 additionally supports reactive return types — a Publisher, an RxJava type, or a Kotlin suspending function — which Spring subscribes to on the configured schedule.
Scheduling attributes
| Attribute | Description |
|---|---|
|
Delay between the end of one execution and the start of the next. Use this when executions must never overlap and the work takes a variable amount of time. |
|
Interval between the start of successive executions. If an execution overruns the interval, the next one is queued rather than skipped. |
|
Delay before the first execution. Combine with |
|
A cron expression, for schedules that are calendar-based rather than interval-based. |
|
The time zone a |
|
The unit for |
Each duration attribute has a String variant — fixedDelayString, fixedRateString, initialDelayString — that accepts a property placeholder, and cron accepts one directly. This is the cleanest way to make a schedule configurable per environment:
class ReportService {
@Scheduled(cron = '${myapp.digest.cron}')
void sendDailyDigest() {
// ...
}
@Scheduled(fixedDelayString = '${myapp.poll.delay}', timeUnit = TimeUnit.SECONDS)
void pollUpstream() {
// ...
}
}
myapp:
digest:
cron: '0 0 6 * * ?'
poll:
delay: 30
A cron value of - disables the task entirely, which is a convenient way to turn a scheduled method off in a particular environment without changing code.
|
The scheduler thread pool
Spring Boot configures the scheduler with a single thread by default. Every @Scheduled method in the application shares it, so one long-running or blocked task delays every other scheduled task. This is the second most common surprise with @Scheduled, after clustering.
Increase the pool through configuration:
spring:
task:
scheduling:
pool:
size: 5
thread-name-prefix: scheduling-
shutdown:
await-termination: true
await-termination-period: 60s
On Java 21 and above, setting spring.threads.virtual.enabled to true switches the scheduler to virtual threads, in which case the pool size no longer applies and spring.task.scheduling.simple.concurrency-limit bounds concurrency instead.
Running work asynchronously
@Scheduled is about when work runs. To run work off the request thread on demand, use @Async together with @EnableAsync:
import org.springframework.scheduling.annotation.Async
class NotificationService {
@Async
void notifyAsync(Long userId) {
// returns to the caller immediately
}
}
@Async methods use the spring.task.execution. pool, which is separate from the scheduling pool. As with any Spring proxy-based annotation, calling an @Async or @Scheduled method from another method on the *same bean bypasses the proxy and runs it synchronously.
See also Asynchronous Programming for the Grails-specific async APIs.
The limitation
Nothing in @Scheduled is aware of other instances of your application. Each instance builds its own scheduler from the annotations it finds at startup, in memory, with no shared state — so there is no coordination point, and nowhere for one instance to discover that another has already claimed a firing. With three instances deployed, a @Scheduled(cron = '0 0 6 * * ?') method runs three times at 6:00am — concurrently, on three machines, against the same database.
Common workarounds — designating one instance "the scheduler" with a Spring profile, or taking an advisory lock inside the method — either forfeit high availability or reimplement, badly, what a clustered scheduler already does.
For anything that must run exactly once per schedule across a deployment, see Advanced Scheduling.
20.2 Advanced Scheduling
Advanced scheduling in Grails is provided by the Quartz plugin, which adds a Job artefact type backed by the Quartz Enterprise Job Scheduler.
Use it when you have a scheduling requirement Spring’s @Scheduled cannot meet: a schedule that changes at runtime, a schedule that must survive a restart, jobs that operators need to pause and resume, work that must be recovered after a crash — or, most commonly, a deployment of more than one instance, where the schedule must fire exactly once across the cluster.
If your application runs as a single instance and its schedules are fixed, you do not need this section. Spring’s @Scheduled covers that case with no dependencies and nothing to configure — see Basic Scheduling.
|
Clustering is opt-in
Adding the plugin does not make your schedules cluster-safe.
Out of the box Quartz uses its in-memory RAMJobStore: the schedule is rebuilt from your source at every startup and lives entirely inside a single process, with no knowledge of any other instance. In that default configuration Quartz duplicates work across instances exactly as @Scheduled does.
Clustering has to be turned on deliberately, and it has three requirements — a JDBC job store, a shared set of Quartz tables, and explicit clustering properties. All of it is covered in Clustering. If you adopted this plugin in order to run on several instances, that section is the part that actually solves your problem.
What the plugin provides
A job is an artefact: a class in grails-app/jobs whose name ends in Job and which declares an execute method. The plugin discovers those classes, builds the Quartz JobDetail and Trigger objects described by their triggers block, and registers them with a Scheduler bean named quartzScheduler.
-
A
triggersDSL supporting simple, cron, and custom triggers, with multiple triggers per job. -
Dynamic scheduling — schedule, reschedule, unschedule, and fire jobs at runtime from anywhere in your application.
-
A
jobManagerServicefor inspecting, pausing, and resuming jobs and triggers. -
Optional JDBC persistence, so a schedule survives a restart.
-
Optional clustering, so a trigger fires exactly once across a multi-instance deployment.
-
Recovery of jobs that were executing when an instance died.
-
Binding of a Hibernate
Sessionto each job’s execution thread, so jobs can use GORM the same way a controller or service does.
Nothing prevents using Quartz and @Scheduled in the same application — they are independent schedulers — but a given piece of work should belong to exactly one of them.
Start with Installation.
20.2.1 Installation
Add the plugin to the dependencies block of your build.gradle. The version is managed by the Grails BOM, so it does not need to be specified:
dependencies {
implementation 'org.apache.grails:grails-quartz'
}
No further setup is required. By default the plugin uses Quartz’s in-memory RAMJobStore, so jobs are registered from your source at startup and require no database. See Configuration for the available options, including JDBC-backed persistence.
Creating your first job
Use the create-job command to generate a job under grails-app/jobs:
grails create-job MyJob
That produces:
package com.mycompany.myapp
class MyJob {
static triggers = {
simple repeatInterval: 5000L // execute the job every 5 seconds
}
void execute() {
println 'Job run!'
}
}
Jobs are Spring beans and are autowired by name, so you can inject services and other beans into them:
class MyJob {
MyService myService
static triggers = {
cron cronExpression: '0 0 6 * * ?'
}
void execute() {
myService.doSomething()
}
}
20.2.2 Writing Jobs
A job is a class in grails-app/jobs whose name ends in Job and which declares an execute method. The triggers block describes when it runs:
class MyJob {
static group = 'MyGroup'
static description = 'Example job with a simple trigger'
static triggers = {
simple(
name: 'mySimpleTrigger',
startDelay: 60000,
repeatInterval: 1000
)
}
void execute() {
println 'Job run!'
}
}
The example above waits one minute after the scheduler starts, then calls execute() every second. repeatInterval and startDelay are expressed in milliseconds and must be Integer or Long; a negative value is rejected at startup. When they are omitted, the defaults in Triggers apply.
The name of a trigger must be unique across every trigger in the application. If you do not supply one, the plugin generates a name from the job name and a counter.
Scheduling a cron job
Jobs can also be scheduled with a cron expression — for example "at 6:00am every day" or "at 1:30am on the last Friday of the month":
class MyJob {
static group = 'MyGroup'
static description = 'Example job with a cron trigger'
static triggers = {
cron(
name: 'myTrigger',
cronExpression: '0 0 6 * * ?'
)
}
void execute() {
println 'Job run!'
}
}
The fields of a cronExpression are:
cronExpression: "s m h D M W Y"
| | | | | | - Year (optional)
| | | | | - Day of Week, 1-7 or SUN-SAT, ?
| | | | - Month, 1-12 or JAN-DEC
| | | - Day of Month, 1-31, ?
| | - Hour, 0-23
| - Minute, 0-59
- Second, 0-59
|
See the Quartz CronExpression documentation for the full set of special characters.
Receiving the execution context
execute may optionally accept the Quartz JobExecutionContext, which carries the merged job data map and the fire-time details:
class MyJob {
static triggers = {
simple repeatInterval: 5000L
}
void execute(JobExecutionContext context) {
println context.mergedJobDataMap.foo
}
}
Interrupting a running job
A job that declares an interrupt() method can be interrupted mid-execution through the jobManagerService. The plugin discovers the method reflectively, so the job class does not need to implement any Quartz interface:
class MyJob {
private volatile boolean cancelled = false
static triggers = {
simple repeatInterval: 5000L
}
void execute() {
while (!cancelled) {
// do a unit of work
}
}
void interrupt() {
cancelled = true
}
}
Calling interruptJob on a job that has no interrupt() method raises an UnableToInterruptJobException.
20.2.3 Triggers
The triggers block of a job is a small DSL supporting three kinds of trigger. A job may declare as many triggers as it needs.
class MyJob {
static triggers = {
simple(
name: 'simpleTrigger',
startDelay: 10000,
repeatInterval: 30000,
repeatCount: 10
)
cron(
name: 'cronTrigger',
startDelay: 10000,
cronExpression: '0/6 * 15 * * ?',
timeZone: TimeZone.getTimeZone('GMT-8') // optional
)
custom(
name: 'customTrigger',
triggerClass: MyTriggerClass,
myParam: myValue,
myAnotherParam: myAnotherValue
)
}
void execute() {
println 'Job run!'
}
}
With that configuration the job runs 11 times at 30-second intervals, starting 10 seconds after the scheduler starts (the simple trigger); it also runs every 6 seconds during the 15th hour — 15:00:00, 15:00:06, 15:00:12, … (the cron trigger); and it runs every time the custom trigger fires.
simple
Executes repeatedly on a fixed interval.
| Attribute | Default | Description |
|---|---|---|
|
|
Uniquely identifies the trigger. Must be unique across the whole application. |
|
|
The trigger group. |
|
|
Delay in milliseconds between scheduler startup and the first execution. Must be a non-negative |
|
|
Interval in milliseconds between consecutive executions. |
|
|
The trigger fires |
cron
Executes according to a cron expression. See Writing Jobs for the expression format.
| Attribute | Default | Description |
|---|---|---|
|
|
Uniquely identifies the trigger. Must be unique across the whole application. |
|
|
The trigger group. |
|
|
Delay in milliseconds between scheduler startup and the first execution. |
|
|
The cron expression that determines the firing schedule. An invalid expression fails at startup. |
|
The system default |
The |
custom
Delegates the firing schedule to your own implementation of the Quartz Trigger interface. This is how you use any Quartz trigger type the DSL does not model directly, such as DailyTimeIntervalTriggerImpl or CalendarIntervalTriggerImpl.
| Attribute | Default | Description |
|---|---|---|
|
required |
A class implementing |
|
|
Uniquely identifies the trigger. |
|
|
The trigger group. |
|
|
Delay in milliseconds between scheduler startup and the first execution. |
Any further attributes are set as properties on the trigger instance, so you can configure the trigger with whatever its class exposes:
class MyJob {
static triggers = {
custom(
name: 'dailyTrigger',
triggerClass: DailyTimeIntervalTriggerImpl,
startTimeOfDay: new TimeOfDay(9, 0, 0),
endTimeOfDay: new TimeOfDay(17, 0, 0),
repeatIntervalUnit: DateBuilder.IntervalUnit.HOUR,
repeatInterval: 1
)
}
void execute() {
println 'Job run!'
}
}
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
Configuration.
The triggers closure is given access to the grailsApplication object, so trigger attributes can be driven from configuration:
class MyJob {
static triggers = {
simple repeatInterval: grailsApplication.config.getProperty('myapp.myJob.interval', Long, 60000L)
}
void execute() {
println 'Job run!'
}
}
20.2.4 Job Properties
Beyond the triggers block, a job class can declare a number of static properties that control how it is registered with the scheduler.
| Property | Default | Description |
|---|---|---|
|
|
The Quartz job group the job belongs to. Grouping lets you pause or resume a set of related jobs together — see Managing Jobs at Runtime. |
|
|
A short description of the job. It is carried on the Quartz |
|
|
Whether a new execution may begin while a previous execution of the same job is still running. Set to |
|
|
Whether a Hibernate |
|
|
Set to |
|
|
Whether the job is retained by the scheduler once it has no remaining triggers. Only meaningful with a persistent job store. |
|
|
Whether the job is re-executed after a hard shutdown — the process crashing, or the machine being switched off — while it was running. On the recovered execution, |
For example:
class ReportJob {
static group = 'reporting'
static description = 'Generates the nightly sales report'
static concurrent = false
static requestsRecovery = true
static triggers = {
cron cronExpression: '0 0 2 * * ?'
}
void execute() {
// ...
}
}
20.2.5 Dynamic Job Scheduling
Triggers declared in a job’s triggers block are fixed at startup. To schedule work at runtime, the plugin adds a set of static methods to every job class:
// fire the job immediately, once
MyJob.triggerNow()
MyJob.triggerNow(foo: 'It works!')
// a simple trigger: repeat every `repeatInterval` ms, `1 + repeatCount` times
MyJob.schedule(1000L)
MyJob.schedule(1000L, 10)
MyJob.schedule(1000L, 10, [foo: 'bar'])
// a cron trigger
MyJob.schedule('0 0 6 * * ?')
MyJob.schedule('0 0 6 * * ?', [foo: 'bar'])
// a single execution at a specific date and time
MyJob.schedule(new Date() + 1)
MyJob.schedule(new Date() + 1, [foo: 'bar'])
// any Quartz trigger you have built yourself
MyJob.schedule(myTrigger)
MyJob.schedule(myTrigger, [foo: 'bar'])
// replace an existing trigger with a new one
MyJob.reschedule(myTrigger)
// remove a named trigger, leaving the job registered
MyJob.unschedule('myTriggerName')
MyJob.unschedule('myTriggerName', 'myTriggerGroup')
// remove the job and all of its triggers from the scheduler
MyJob.removeJob()
Every method except removeJob and unschedule accepts an optional Map of parameters. Those entries are placed in the trigger’s job data map and are available to the job through the execution context:
class MyJob {
void execute(JobExecutionContext context) {
println context.mergedJobDataMap.foo
}
}
class MyController {
def index() {
MyJob.triggerNow(foo: 'It works!')
render 'scheduled'
}
}
When a trigger is passed directly to schedule, its job key is rewritten to point at the job class, so the trigger must be mutable — that is, an instance of org.quartz.spi.MutableTrigger, which every trigger implementation shipped with Quartz is. A trigger that is neither mutable nor already keyed to the job is rejected.
The default trigger group used by unschedule is GRAILS_TRIGGERS.
These methods only work on a job that the plugin has registered with the scheduler, which it does for every enabled job artefact while the application starts. Calling them on a job that is turned off with jobEnabled = false, or on a class the application does not manage as a job artefact, throws an IllegalStateException reporting that the job is not registered with a scheduler.
A null argument is rejected with an IllegalArgumentException that names the argument which is missing. Since the runtime type of null carries no information, a null passed by a caller that is not statically compiled always resolves to schedule(Trigger), whichever method the caller meant to reach:
// reports a null trigger, even though the interval read from the configuration is what is null
MyJob.schedule(grailsApplication.config.getProperty('my.job.repeatInterval', Long))
20.2.6 Managing Jobs at Runtime
The plugin provides a jobManagerService bean for inspecting and controlling the scheduler at runtime. Inject it like any other Grails service:
class JobAdminController {
JobManagerService jobManagerService
def index() {
[jobs: jobManagerService.allJobs, running: jobManagerService.runningJobs]
}
}
Inspecting jobs
| Method | Description |
|---|---|
|
Returns a |
|
Returns the |
|
Returns the |
A JobDescriptor exposes the job’s name and group, the underlying Quartz jobDetail, and a triggerDescriptors list. Each TriggerDescriptor carries the trigger’s name and group, its current state as a Trigger.TriggerState, and the underlying Quartz trigger — from which the fire times and schedule details can be read.
Controlling jobs and triggers
| Method | Description |
|---|---|
|
Pauses or resumes a single job. |
|
Pauses or resumes every job in a group. |
|
Pauses or resumes a single trigger. |
|
Pauses or resumes every trigger in a group. |
|
Pauses or resumes the entire scheduler. |
|
Removes all of a job’s triggers, leaving the job itself registered. |
|
Removes the job and its triggers from the scheduler. |
|
Interrupts a currently executing job. The job class must declare an |
The Quartz Scheduler itself is also available as the quartzScheduler bean if you need an operation the service does not wrap.
20.2.7 Long-Running Jobs
A job whose execution takes longer than the interval of the trigger that started it needs three decisions: whether its executions may overlap, how many of the scheduler’s threads they may occupy, and how an execution that has run for too long is stopped.
Overlapping executions
concurrent defaults to true, so every fire starts an execution whether or not the previous one has finished. A job that runs for hours on a trigger that fires every few minutes therefore stacks executions up, each holding one of the scheduler’s worker threads for its whole run. Once every thread is occupied nothing fires at all: the scheduler looks stuck, the log fills with misfires, and no work gets done.
Set concurrent = false on such a job, and the scheduler never runs two of its executions at the same time:
class ImportJob {
static concurrent = false
static triggers = {
simple repeatInterval: 100_000L
}
void execute() {
// ...
}
}
A fire that arrives while the job is still running is then held, and what becomes of it is the trigger’s misfire instruction.
Threads
Jobs run on a fixed pool of worker threads — ten of them unless configured otherwise. A long-running job holds one of those threads for its entire execution, so the pool has to be large enough for every execution that can be in flight at once, plus the short jobs that must keep firing meanwhile:
quartz:
threadPool:
threadCount: 25
Misfires
A trigger misfires when the scheduler cannot fire it when it was due — because the previous execution is still running and the job is not concurrent, or because no worker thread is free. What happens to the fires that were missed is the trigger’s misfireInstruction, set like any other trigger attribute. Left unset, a trigger uses Quartz’s smart policy, which depends on the trigger type: an indefinitely repeating simple trigger drops the fires it missed and carries on at its next scheduled time, while a cron trigger fires once immediately.
So to have an hourly report skip the run it missed instead of starting it the moment the long execution finishes:
class ReportJob {
static concurrent = false
static triggers = {
cron cronExpression: '0 0 * * * ?',
misfireInstruction: CronTrigger.MISFIRE_INSTRUCTION_DO_NOTHING
}
void execute() {
// ...
}
}
The instructions to choose from are the constants of the Quartz trigger type — SimpleTrigger and CronTrigger. How late a fire may be before the scheduler counts it as a misfire is quartz.jobStore.misfireThreshold, in milliseconds; it is passed straight through to Quartz, so set it explicitly if the tolerance matters to your schedule.
Watching what is running
jobManagerService.runningJobs returns the JobExecutionContext of every execution in flight. Each one carries the job it belongs to as jobDetail.key and the moment its execution began as fireTime, which is all that is needed to find work that has been running for too long:
jobManagerService.runningJobs.each { JobExecutionContext context ->
println "${context.jobDetail.key} has been running since ${context.fireTime}"
}
Stopping an execution
There is no timeout setting. Nothing can safely stop a thread in the middle of arbitrary work, so a job is asked to stop itself instead: declare an interrupt() method on the job, and the plugin routes interruption requests to it. It is up to the job to notice and return from execute.
class ImportJob {
static concurrent = false
private volatile boolean interrupted
static triggers = {
simple repeatInterval: 100_000L
}
void execute() {
for (batch in batches) {
if (interrupted) {
return
}
process batch
}
}
void interrupt() {
interrupted = true
}
}
A field is enough to carry the flag: every execution is given its own instance of the job class, so nothing is shared between executions.
Ask for the interruption from anywhere in the application, by job group and job name:
jobManagerService.interruptJob('GRAILS_JOBS', 'com.example.ImportJob')
A job that does not declare interrupt() cannot be stopped; the request raises an UnableToInterruptJobException saying so. Code that interrupts jobs it does not own — a watchdog job which interrupts whatever it finds running past a limit, for instance — has to allow for that.
Hibernate sessions
A Hibernate Session is bound to the job’s thread for the whole execution unless the job sets sessionRequired = false. A session that lives for hours keeps every entity it has loaded in its first-level cache, so memory grows with the work done and the entities it holds drift further from the database.
Process the work of a long job in chunks, each in a session of its own, so neither grows without bound:
class ImportJob {
static concurrent = false
void execute() {
batches.each { batch ->
Invoice.withNewSession {
process batch
}
}
}
}
Note that the session is bound regardless of sessionRequired when quartz.jdbcStore is enabled, because the job store needs it.
20.2.8 Configuration
The plugin is configured under the quartz key in grails-app/conf/application.yml:
quartz:
autoStartup: true
jdbcStore: false
Plugin options
| Property | Default | Description |
|---|---|---|
|
|
Set to |
|
|
Whether the scheduler is started once the application has finished bootstrapping. When |
|
|
Set to |
|
|
The name of the Spring bean holding the |
|
|
When |
|
|
Whether shutdown blocks until currently executing jobs have finished. Set to |
|
|
Whether the scheduler is registered with the Quartz |
|
|
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 |
|
The bean name |
The name given to the scheduler. Set this when several applications share a job store, so their schedulers do not collide. |
Passing options straight through to Quartz
Every key beneath quartz is flattened and handed to Quartz itself with an org.quartz. prefix, so any Quartz configuration property can be set the same way. For example, this configures the thread pool:
quartz:
threadPool:
threadCount: 10
threadPriority: 5
which Quartz receives as org.quartz.threadPool.threadCount and org.quartz.threadPool.threadPriority. The plugin options in the table above are read from the same flattened set, which is why they share the quartz prefix.
Logging
A logger is injected into every job class, so no setup is required to use log from within execute. To change the level, configure the grails.app.jobs logger — for example in grails-app/conf/logback.groovy:
logger 'grails.app.jobs', DEBUG
Any exception thrown from a job is logged by a global job listener registered by the plugin, so failures are never silently swallowed.
Hibernate sessions and jobs
When a Hibernate plugin is on the classpath, the plugin binds a Hibernate Session to the thread for the duration of each job execution. This is what allows a job to use GORM — lazy-loading collections, or applying unique constraints — exactly as a service would.
Set sessionRequired to false on a job to opt out of this behaviour. This is rarely useful; note that the session is bound regardless when jdbcStore is enabled, because the job store needs it.
class MyJob {
static sessionRequired = false
}
The binding covers the job’s execute method only. Job listeners run outside it and must manage their
own session — see Job listeners.
Job listeners
To run code around every job — binding a security context, populating a logging context, or any other
setup a job inherits rather than repeats — register a Quartz JobListener with the scheduler. This is
the supported way to share behaviour across all jobs; the plugin uses the same mechanism internally for
its own exception logging.
Implement JobListener (or extend Quartz’s JobListenerSupport, which no-ops the callbacks you do not
need):
import org.quartz.JobExecutionContext
import org.quartz.JobExecutionException
import org.quartz.listeners.JobListenerSupport
class SecurityContextJobListener extends JobListenerSupport {
String getName() { 'securityContextJobListener' }
void jobToBeExecuted(JobExecutionContext context) {
// bind whatever every job should see
}
void jobWasExecuted(JobExecutionContext context, JobExecutionException exception) {
// and unbind it again
}
}
Register it against the quartzScheduler bean, typically from BootStrap:
import org.quartz.Scheduler
class BootStrap {
Scheduler quartzScheduler
def init = { servletContext ->
quartzScheduler.listenerManager.addJobListener(new SecurityContextJobListener())
}
}
A listener registered with no matcher observes every job. To restrict it to particular jobs, pass a
Matcher — KeyMatcher.keyEquals(…) for a single job, or GroupMatcher.jobGroupEquals(…) for a
whole job group.
A job listener has no Hibernate Session bound to its thread. The session opened for the job
is flushed and closed before jobWasExecuted is called, so a listener that needs GORM must open its own
with withNewSession or withTransaction. See Hibernate sessions and jobs.
|
Persistent job storage
By default Quartz keeps jobs and triggers in memory, so they are rebuilt from your source every time the application starts. Setting jdbcStore to true switches Quartz to its JDBC job store, which persists them in the database:
quartz:
jdbcStore: true
Two things are required for this to work:
-
The Quartz tables must exist in the database. Quartz ships the schema for each supported database with its distribution.
-
A
quartz.propertiesfile on the classpath, or equivalentquartz.*configuration, describing the job store — see the Quartz documentation on job stores.
Persistent storage interacts with the durability and requestsRecovery job properties described in Job Properties.
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 jobManagerService or from the Quartz tables.
Trigger names are permanent
A trigger is identified by its name and group, and with a persistent job store that identity outlives the application. On startup the plugin reconciles the triggers a job declares against the ones already in the store: a declared trigger whose name is already present is rescheduled, and a declared trigger that is missing is added. A trigger in the store that the job no longer declares is left alone, because the plugin cannot tell it apart from one scheduled at runtime through dynamic scheduling or jobManagerService.
The practical consequence is that renaming a trigger does not replace it — it adds a second one, and
the job then fires on both the old schedule and the new one. Changing the cronExpression of an
existing trigger is safe; changing its name is not.
This applies to the generated names too. A trigger declared without a name is named
<jobClassName><n>, numbered in declaration order — so inserting a trigger ahead of an existing one
renumbers the rest and orphans their old names. Give every trigger an explicit name when using a
persistent job store, and treat those names as permanent.
|
class ReportJob {
static triggers = {
cron name: 'reportNightly', cronExpression: '0 0 2 * * ?' // renaming this later adds a trigger
}
void execute() { }
}
If a trigger has already been orphaned, remove it from the scheduler — through jobManagerService, or by deleting the row from the Quartz tables — before restarting. Nothing detects or reports the duplicate on its own.
Clustering
Quartz can coordinate a schedule across several instances of your application, so a given trigger fires exactly once no matter how many instances are deployed. This requires jdbcStore, a shared set of Quartz tables, and a handful of additional properties.
Clustering is covered in full — including the requirements and the pitfalls — in the Clustering section.
20.2.9 Clustering
By default the Quartz plugin uses Quartz’s in-memory RAMJobStore: the schedule is rebuilt from your source at every startup and lives entirely inside one process. That is fine for a single instance, but it means a deployment of three instances fires every trigger three times, exactly as Basic Scheduling does.
Clustering fixes that. It is not on by default and requires deliberate configuration.
| Duplicate firing is a quiet failure. Nothing throws, nothing is logged, and it usually does not surface in a single-instance test environment. It shows up in production as duplicated emails, double-counted totals, or deadlocks between instances competing for the same rows. |
How Quartz clustering works
Quartz clusters through the database. Every instance is configured with the same scheduler name and points at the same set of Quartz tables. When a trigger’s firing time arrives, each instance races to acquire a row lock on it; the first to take the lock is the one that fires it, and the others move on. The result is load balancing across the cluster with exactly-once firing per trigger.
Failover works from the same mechanism. Instances check in on an interval; when one stops checking in, another detects the failure and recovers its work — re-firing the jobs that declared requestsRecovery.
Enabling clustering
Three things are required.
1. A JDBC job store. Clustering works only with the JDBC job store — the default in-memory RAMJobStore cannot cluster, because there is nothing shared to coordinate through.
quartz:
jdbcStore: true
2. The Quartz tables. Quartz ships a schema script for each supported database with its distribution; create the QRTZ_* tables before starting the application.
3. Clustering configuration. Every key under quartz is passed through to Quartz with an org.quartz. prefix, so the standard clustering properties are set the same way:
quartz:
jdbcStore: true
scheduler:
instanceName: myAppScheduler # identical on every instance
instanceId: AUTO # unique per instance; AUTO generates one
jobStore:
isClustered: true
clusterCheckinInterval: 20000 # milliseconds
Every instance must use the same instanceName and the same tables. Only instanceId and the thread-pool size should differ.
Requirements and pitfalls
|
If you are not clustering
A single-instance deployment does not need any of this, and quartz.jdbcStore should be left at its default of false — the in-memory store is simpler and faster, and the schedule is rebuilt from your source at every startup.
If you have a single instance but need the schedule itself to survive restarts — because it is modified at runtime — enable jdbcStore without isClustered.
20.2.10 Useful Links
The plugin is a thin, convention-driven layer over Quartz itself. When you need detail the guide does not cover — the full cron syntax, the trigger types, job-store and clustering configuration — the Quartz documentation is the authority.
-
Quartz Enterprise Job Scheduler — the project home page.
-
Quartz documentation — tutorials, configuration reference, and cookbook.
-
Jobs and triggers — how Quartz models the two, and the trigger types available.
-
CronExpression— the complete cron expression syntax, including the special characters. -
Quartz configuration reference — every
org.quartz.*property that can be set through thequartzblock described in Configuration.