(Quick Reference)

20 Background Jobs

Version: 8.0.0-SNAPSHOT

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 @Scheduled annotation. 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. @Scheduled fires 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 @Scheduled does. Clustering is opt-in and requires a JDBC job store, a shared set of Quartz tables, and explicit configuration — see Clustering.

Comparison

Basic Scheduling Advanced Scheduling

Mechanism

Spring @Scheduled

Quartz plugin

Setup

Built in — nothing to add

Add grails-quartz; clustering additionally needs a database schema and configuration

Where the schedule lives

An annotation on a bean method

A triggers block on a job artefact

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:

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

fixedDelay

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.

fixedRate

Interval between the start of successive executions. If an execution overruns the interval, the next one is queued rather than skipped.

initialDelay

Delay before the first execution. Combine with fixedDelay or fixedRate.

cron

A cron expression, for schedules that are calendar-based rather than interval-based.

zone

The time zone a cron expression is resolved against. Defaults to the server’s local time zone — worth setting explicitly for anything that must fire at a particular wall-clock time.

timeUnit

The unit for fixedDelay, fixedRate, and initialDelay. Defaults to milliseconds.

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() {
        // ...
    }
}
application.yml
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:

application.yml
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 triggers DSL 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 jobManagerService for 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 Session to 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
  • Year is the only optional field; the rest are mandatory.

  • Day-of-Week and Month are case-insensitive, so DEC = dec = Dec.

  • Either Day-of-Week or Day-of-Month must be ?. You cannot specify both, nor leave both as the * wildcard — this is a departure from the unix crontab specification.

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

name

<jobName><n>

Uniquely identifies the trigger. Must be unique across the whole application.

group

GRAILS_TRIGGERS

The trigger group.

startDelay

0

Delay in milliseconds between scheduler startup and the first execution. Must be a non-negative Integer or Long.

repeatInterval

60000

Interval in milliseconds between consecutive executions.

repeatCount

-1

The trigger fires 1 + repeatCount times and then stops. Use 0 for a one-shot job, or -1 to repeat indefinitely.

cron

Executes according to a cron expression. See Writing Jobs for the expression format.

Attribute Default Description

name

<jobName><n>

Uniquely identifies the trigger. Must be unique across the whole application.

group

GRAILS_TRIGGERS

The trigger group.

startDelay

0

Delay in milliseconds between scheduler startup and the first execution.

cronExpression

0 0 6 * * ?

The cron expression that determines the firing schedule. An invalid expression fails at startup.

timeZone

The system default

The java.util.TimeZone the cron expression is evaluated in.

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

triggerClass

required

A class implementing org.quartz.Trigger. Omitting it, or supplying a class that does not implement Trigger, fails at startup.

name

<jobName><n>

Uniquely identifies the trigger.

group

GRAILS_TRIGGERS

The trigger group.

startDelay

0

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

group

GRAILS_JOBS

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.

description

Grails Job

A short description of the job. It is carried on the Quartz JobDetail and can be read back at runtime through the jobManagerService.

concurrent

true

Whether a new execution may begin while a previous execution of the same job is still running. Set to false to serialize executions — the job is then registered with Quartz’s @DisallowConcurrentExecution semantics.

sessionRequired

true

Whether a Hibernate Session is bound to the job’s execution thread. See Configuration.

jobEnabled

true

Set to false to leave the job out of the scheduler. A job that becomes disabled is removed from the scheduler, along with its triggers, when the application reloads.

durability

true

Whether the job is retained by the scheduler once it has no remaining triggers. Only meaningful with a persistent job store.

requestsRecovery

false

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, JobExecutionContext.isRecovering() returns true. Only meaningful with a persistent job store.

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

getAllJobs()

Returns a Map<String, List<JobDescriptor>> of every registered job, keyed by job group.

getJobs(String group)

Returns the JobDescriptor list for a single job group.

getRunningJobs()

Returns the JobExecutionContext of every job currently executing.

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

pauseJob(String group, String name)
resumeJob(String group, String name)

Pauses or resumes a single job.

pauseJobGroup(String group)
resumeJobGroup(String group)

Pauses or resumes every job in a group.

pauseTrigger(String group, String name)
resumeTrigger(String group, String name)

Pauses or resumes a single trigger.

pauseTriggerGroup(String group)
resumeTriggerGroup(String group)

Pauses or resumes every trigger in a group.

pauseAll()
resumeAll()

Pauses or resumes the entire scheduler.

unscheduleJob(String group, String name)

Removes all of a job’s triggers, leaving the job itself registered.

removeJob(String group, String name)

Removes the job and its triggers from the scheduler.

interruptJob(String group, String name)

Interrupts a currently executing job. The job class must declare an interrupt() method; if it does not, an UnableToInterruptJobException is raised.

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

quartz.pluginEnabled

true

Set to false to disable the plugin entirely. No scheduler, job beans, or listeners are registered, and no jobs run. Useful for test environments.

quartz.autoStartup

true

Whether the scheduler is started once the application has finished bootstrapping. When false, jobs are registered but the scheduler stays idle until you start it yourself via the quartzScheduler bean.

quartz.jdbcStore

false

Set to true to have Quartz persist jobs and triggers in a database instead of the in-memory RAMJobStore. See Persistent job storage.

quartz.jdbcStoreDataSource

dataSource

The name of the Spring bean holding the DataSource Quartz should use when jdbcStore is enabled. Set this to use a data source other than the application’s default.

quartz.purgeQuartzTablesOnStartup

false

When true — and only when jdbcStore is enabled and a Hibernate plugin is present — every Quartz table is emptied during startup. Intended for development and testing.

quartz.waitForJobsToCompleteOnShutdown

true

Whether shutdown blocks until currently executing jobs have finished. Set to false to shut the scheduler down immediately.

quartz.exposeSchedulerInRepository

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.

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 MatcherKeyMatcher.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:

  1. The Quartz tables must exist in the database. Quartz ships the schema for each supported database with its distribution.

  2. A quartz.properties file on the classpath, or equivalent quartz.* 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.

application.yml
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:

application.yml
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
  • Clocks must be synchronized. Cluster nodes coordinate on time; their clocks must be within about a second of each other. Run NTP or an equivalent time-sync service on every node.

  • Never point a non-clustered scheduler at clustered tables. Starting an instance with isClustered disabled against the same tables another instance is using causes data corruption and erratic firing. If one instance is clustered, all of them must be.

  • instanceId must be unique per node. AUTO generates one; a hard-coded value copied across nodes breaks failover detection.

  • 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 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 The application only removes its own jobs.

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.

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.