Listeners and Plugins
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
Quartz tells the application what it is doing through two extension points. Listeners are callbacks the scheduler invokes around every job execution, every trigger firing and every scheduler-level event: they are how you audit, measure, veto and react. Plugins are small components the scheduler creates from configuration when it starts: some ship with Quartz (history logging, loading jobs from XML, a shutdown hook, an interrupt monitor) and some you write. This page covers both, how to register them under Spring Boot (where Boot does less than you might expect), and the traps that the Quartz documentation does not mention. It follows Jobs & Job Data, which explains the job lifecycle the listeners observe, and it feeds Observability, which builds Micrometer meters and a health indicator on the listeners shown here.
It was written against Quartz 2.5.2, Spring Boot 4.1.1, Spring Framework 7.0.9 and Java 21 and verified on 5 October 2026:
every behaviour below was run in a test project and the results are quoted, not assumed. On Spring Boot 3.5.16 the
Quartz API and the plugin properties are identical (Boot 3.5 also manages Quartz 2.5.2); only the Spring packages differ:
SchedulerFactoryBeanCustomizer lives in org.springframework.boot.autoconfigure.quartz instead of
org.springframework.boot.quartz.autoconfigure, and Spring Framework is 6.2.19. The examples use the running Bookshelf
scenario: an audit log of job runs, a maintenance window that must pause the reminder jobs, and a time budget for a long
catalogue import.
The three listener types and the plugin SPI
Quartz defines three listener interfaces. Each is a plain Java interface in the org.quartz package, and each has an
abstract *Support base class in org.quartz.listeners with empty methods, so an implementation overrides only what it
needs.
| Interface | Observes | Callbacks |
|---|---|---|
|
One execution of a job |
|
|
One firing of a trigger, plus misfires |
|
|
Scheduler-wide events and changes to jobs and triggers |
|
Source: Quartz API: JobListener, TriggerListener and SchedulerListener; the method lists were read from the 2.5.2 sources.
The support classes are JobListenerSupport, TriggerListenerSupport (its vetoJobExecution returns false) and
SchedulerListenerSupport. The same package also holds JobChainingJobListener (starts a follow-up job when a job
completes) and the BroadcastJobListener, BroadcastTriggerListener and BroadcastSchedulerListener helpers that fan one
registration out to several delegates.
A figure helps to fix the order of the callbacks. For one successful execution of a one-shot job, a recording listener
registered as job, trigger and scheduler listener saw exactly this sequence (a failing job produces the same sequence with
the exception passed to jobWasExecuted):
Two details that the diagram cannot show. First, triggerMisfired is not part of an execution at all: it is called when
the scheduler’s misfire handling applies a misfire instruction (see Misfires). Second, a recording trigger listener that vetoed a recurring trigger saw triggerFired,
vetoJobExecution (returning true) and jobExecutionVetoed on every tick, and never triggerComplete.
Plugins implement a fourth interface, org.quartz.spi.SchedulerPlugin, with three methods: initialize(name, scheduler,
loadHelper) runs while the scheduler is being built, start() when it starts and shutdown() when it shuts down. Most
plugins use initialize to register a listener, which is why the plugins and the listeners belong on one page.
JobListener
A JobListener has a unique, non-empty name (an empty name is rejected with IllegalArgumentException at registration,
and a second listener with the same name replaces the first). The Bookshelf audit listener times every execution and
logs the outcome. JobExecutionContext.put and get hold values for the duration of that single execution, which is
the right place to keep the start time:
public class JobAuditListener extends JobListenerSupport {
private static final Logger log = LoggerFactory.getLogger(JobAuditListener.class);
@Override
public String getName() {
return "bookshelf-job-audit"; // must be unique and non-empty
}
@Override
public void jobToBeExecuted(JobExecutionContext context) {
context.put("auditStartNanos", System.nanoTime());
}
@Override
public void jobExecutionVetoed(JobExecutionContext context) {
log.info("job {} was vetoed by a trigger listener", context.getJobDetail().getKey());
}
@Override
public void jobWasExecuted(JobExecutionContext context, JobExecutionException jobException) {
long tookMs = (System.nanoTime() - (Long) context.get("auditStartNanos")) / 1_000_000;
if (jobException == null) {
log.info("job {} finished in {} ms", context.getJobDetail().getKey(), tookMs);
} else {
log.warn("job {} failed after {} ms: {}", context.getJobDetail().getKey(), tookMs, jobException.getMessage());
}
}
}
Source: Quartz tutorial, lesson 7: TriggerListeners and JobListeners and Quartz cookbook: JobListeners. Run on Quartz 2.5.2.
With the listener registered (see below) every execution produces one line. These are the lines from a test run:
job bookshelf.cacheRefresh finished in 2 ms
job bookshelf.probeA finished in 0 ms
job bookshelf.probeA was vetoed by a trigger listener
Source: measured in a test run on Quartz 2.5.2; see Quartz tutorial, lesson 7: TriggerListeners and JobListeners.
jobWasExecuted receives a non-null jobException when the job failed. A JobExecutionException arrives as thrown; any
other Throwable is wrapped by Quartz in a JobExecutionException (and reported to the scheduler listeners as a
schedulerError, "Job … threw an exception"), so a listener sees every failure in one place and jobException.getMessage()
of a wrapped failure is the wrapper’s message. The metrics example in
Observability relies on this.
TriggerListener and vetoJobExecution
A TriggerListener is called when a trigger fires, whatever job it points at. Its distinctive power is
vetoJobExecution: returning true stops the job from running this time. The trigger is not paused, deleted or marked
as misfired. It simply fires again at its next scheduled time, which makes the veto the right tool for a maintenance
window: while the database is being worked on, the reminder jobs skip their ticks and resume by themselves afterwards.
public class MaintenanceVetoListener extends TriggerListenerSupport {
private final MaintenanceWindow window; // a bean flipped by an admin endpoint
public MaintenanceVetoListener(MaintenanceWindow window) {
this.window = window;
}
@Override
public String getName() {
return "bookshelf-maintenance-veto";
}
@Override
public boolean vetoJobExecution(Trigger trigger, JobExecutionContext context) {
return window.isOpen(); // true = the job does not run this time
}
}
Source: Quartz cookbook: TriggerListeners and Quartz API: TriggerListener. Run on Quartz 2.5.2.
The listener above was registered for the trigger group bookshelf only, next to a second recurring trigger in another
group, both firing every 500 ms. With the window open for 2.1 seconds the matching trigger executed its job 0 times while
the other trigger ran 4 times; the vetoed trigger stayed in state NORMAL, and after the window was closed the job ran
again at the next tick. Three consequences follow:
-
A vetoed tick is lost, not delayed: nothing is queued up and there is no catch-up, and it is not reported as a misfire. If the skipped work must be done later, the job has to compute what is outstanding itself (the overdue-loan reminders do: they select every loan that is overdue and not yet reminded).
-
A veto is evaluated on every node of a cluster separately, because the listener lives in each node’s memory. A flag in a local bean closes the window on one node only; use a shared source (a database row, a feature-flag service) when the window must be cluster-wide.
-
vetoJobExecutionruns on the job’s worker thread before the job, so it must be fast and must not call remote services without a timeout.
SchedulerListener
A SchedulerListener sees changes to the scheduler itself and to the jobs and triggers stored in it. Its most valuable
callback is schedulerError: Quartz reports the failures it handles internally, including exceptions thrown by other
listeners (see below), through it, so a scheduler listener that alerts on errors is the single
place where those otherwise only appear in the log.
public class SchedulerEventsListener extends SchedulerListenerSupport {
private static final Logger log = LoggerFactory.getLogger(SchedulerEventsListener.class);
@Override
public void schedulerError(String msg, SchedulerException cause) {
log.error("Quartz reported an error: {}", msg, cause);
}
@Override
public void triggerFinalized(Trigger trigger) {
log.info("trigger {} will never fire again", trigger.getKey());
}
@Override
public void jobUnscheduled(TriggerKey triggerKey) {
log.info("trigger {} was unscheduled", triggerKey);
}
@Override
public void schedulerShuttingdown() {
log.info("scheduler is shutting down");
}
}
Source: Quartz tutorial, lesson 8: SchedulerListeners and Quartz cookbook: SchedulerListeners. Run on Quartz 2.5.2.
SchedulerListener has no matchers: it receives every event of its scheduler. In the sequence measured above a one-shot
job produced jobScheduled when it was scheduled and, after it ran, triggerFinalized (the trigger has no further fire
time) followed by jobDeleted (the job was not durable, so it disappears with its last trigger).
ListenerManager and matchers
Every scheduler owns a ListenerManager, available as scheduler.getListenerManager(). It is where listeners are
registered and where a job or trigger listener is narrowed to the keys it cares about with Matcher objects. A listener
registered without a matcher gets EverythingMatcher and sees every job (or every trigger).
| Method | Purpose |
|---|---|
|
Register a job listener, optionally with matchers |
|
Change the matchers of an already registered listener, addressed by its name |
|
Remove or look up job listeners |
|
The same operations, with |
|
Scheduler listeners have no name and no matchers |
Source: Quartz API: ListenerManager; signatures read from ListenerManager.java in 2.5.2.
Several matchers on one listener are combined with or: the listener is called when any of them matches (this is how
QuartzScheduler evaluates them, and AndMatcher, OrMatcher and NotMatcher exist when you need other logic).
| Matcher | Matches |
|---|---|
|
Every key; the default when no matcher is given |
|
One exact |
|
Keys by group name |
|
Keys by name, in any group |
|
Boolean combinations of other matchers |
Source: Quartz API: GroupMatcher and the
ListenerManagerImpl source at v2.5.2.
Programmatic registration with matchers looks like this. The first listener sees only the jobs of the bookshelf group;
the second only the one trigger named overdueLoanRemindersTrigger; the third every job whose name does not start with
internal:
ListenerManager listeners = scheduler.getListenerManager();
listeners.addJobListener(new JobAuditListener(), GroupMatcher.jobGroupEquals("bookshelf"));
listeners.addTriggerListener(new MaintenanceVetoListener(window),
KeyMatcher.keyEquals(TriggerKey.triggerKey("overdueLoanRemindersTrigger", "bookshelf")));
listeners.addJobListener(new JobTimeoutListener(),
NotMatcher.not(NameMatcher.jobNameStartsWith("internal")));
Source: Quartz tutorial, lesson 7: TriggerListeners and JobListeners. The matcher classes, factory methods and the or semantics were verified in the 2.5.2 sources; the GroupMatcher form was run (see the veto measurement above).
Listener registrations are held in the scheduler’s memory. They are not stored in the job store, so they are not shared between the nodes of a cluster and they disappear on restart: every node must register its own listeners at startup, which is what the Spring Boot wiring below does.
Registering listeners in Spring Boot
Spring Boot collects JobDetail, Trigger, Calendar and SchedulerFactoryBeanCustomizer beans into the auto-configured
SchedulerFactoryBean, but it does not collect listeners. A JobListener, TriggerListener or SchedulerListener
declared as a plain bean is ignored: in a test run a context with a JobAuditListener bean and a
SchedulerEventsListener bean ended up with empty listener lists. A listener has to be handed to the
SchedulerFactoryBean or to the ListenerManager explicitly. There are three ways.
Way 1: global listeners through a customizer. SchedulerFactoryBean has setGlobalJobListeners,
setGlobalTriggerListeners and setSchedulerListeners (they are inherited from Spring’s SchedulerAccessor), and Boot
gives you a hook to call them: a SchedulerFactoryBeanCustomizer bean. The listeners registered this way have no
matcher, so they apply to everything:
@Configuration(proxyBeanMethods = false)
public class BookshelfListeners {
@Bean
JobAuditListener jobAuditListener() {
return new JobAuditListener();
}
@Bean
SchedulerEventsListener schedulerEventsListener() {
return new SchedulerEventsListener();
}
@Bean
SchedulerFactoryBeanCustomizer bookshelfListeners(JobAuditListener audit, SchedulerEventsListener events) {
return factory -> {
factory.setGlobalJobListeners(audit);
factory.setSchedulerListeners(events);
};
}
}
Source: Spring Boot: Quartz Scheduler,
SchedulerFactoryBeanCustomizer API
and SchedulerAccessor API.
Run on Boot 4.1.1: the scheduler reported the audit listener and the events listener. On Boot 3.5 import
org.springframework.boot.autoconfigure.quartz.SchedulerFactoryBeanCustomizer.
The setters replace the previous value; they do not add to it. Two customizers that both call setGlobalJobListeners
leave only the listeners of the customizer that ran last. In a test run two customizers, one with the audit listener
and one with a second listener, produced a scheduler whose job listeners were [second]: the audit listener was silently
gone. Keep all listener registration in one customizer, or collect the beans:
@Configuration(proxyBeanMethods = false)
public class CollectingListenerConfig {
@Bean
SchedulerFactoryBeanCustomizer listenerRegistration(ObjectProvider<JobListener> jobListeners,
ObjectProvider<TriggerListener> triggerListeners,
ObjectProvider<SchedulerListener> schedulerListeners) {
return factory -> {
factory.setGlobalJobListeners(jobListeners.orderedStream().toArray(JobListener[]::new));
factory.setGlobalTriggerListeners(triggerListeners.orderedStream().toArray(TriggerListener[]::new));
factory.setSchedulerListeners(schedulerListeners.orderedStream().toArray(SchedulerListener[]::new));
};
}
}
Source: SchedulerFactoryBean API.
Run on Boot 4.1.1: with this customizer the audit and events beans were registered and the trigger-listener list was empty
because no TriggerListener bean existed. Every listener bean becomes global, so a listener that needs matchers must not
be a bean of that type (use way 2).
Way 2: matchers through the ListenerManager, before the scheduler starts. SchedulerFactoryBean registers the
listeners it was given right after it creates the scheduler and before it starts it. To add a listener with a matcher
at the same point in the lifecycle, use a SmartInitializingSingleton: it runs after all singletons exist and before the
SmartLifecycle start phase in which the scheduler starts, so no trigger can fire without the listener:
@Bean
SmartInitializingSingleton maintenanceVeto(Scheduler scheduler, MaintenanceWindow window) {
return () -> {
try {
scheduler.getListenerManager().addTriggerListener(
new MaintenanceVetoListener(window),
GroupMatcher.triggerGroupEquals("bookshelf"));
} catch (Exception e) {
throw new IllegalStateException(e);
}
};
}
Source: Spring Framework: Task Execution and Scheduling.
Run on Boot 4.1.1: the scheduler listed the trigger listener bookshelf-maintenance-veto with one GroupMatcher, and it
vetoed only the triggers of that group (see the measurement above).
Way 3: a plugin. A plugin registers its listeners itself, and is configured entirely from properties. That is the route for listeners that should exist in every environment without any Spring wiring; see Plugins.
The Bookshelf keeps the three-way split: global audit and events listeners in the customizer, the maintenance veto with a
matcher in the SmartInitializingSingleton. The two configuration classes above, with the matcher variant, were run
together: the scheduler reported jobListeners=[bookshelf-job-audit], triggerListeners=[bookshelf-maintenance-veto] and
the events listener.
When a listener fails
Listeners run in the scheduler’s threads, in the middle of an execution. Quartz catches what they throw, reports it as a
schedulerError, and carries on, but what happens to the job depends on the callback. The table was measured with a
listener that throws IllegalStateException in one callback at a time and a recurring trigger firing every 500 ms for 2.2
seconds:
| Callback that throws | Job executed? | Observed |
|---|---|---|
|
No |
0 executions in 2.2 s. |
|
No |
0 executions. |
|
Yes |
5 executions. |
|
Yes |
5 executions. |
Source: the JobRunShell source at v2.5.2
contains the three messages quoted above; the counts were measured by running a listener that throws in each callback.
The practical rule follows directly: a bug in a before callback (triggerFired, vetoJobExecution, jobToBeExecuted)
silently stops the job from running, every time, while a bug in an after callback is merely logged. Wrap the body of
every listener method in a try/catch that logs, as the best practices below say, and add a SchedulerListener that
alerts on schedulerError so that the first kind of failure does not go unnoticed.
Plugins
A plugin is configured with properties of the form org.quartz.plugin.<name>.class and, for each bean property of the
plugin class, org.quartz.plugin.<name>.<property>. The <name> is chosen by you; it is passed to the plugin’s
initialize method, and the bundled plugins that register a listener use it as that listener’s name (a test run listed
jobHistory, triggerHistory and interruptMonitor). Quartz instantiates the class, sets the
properties through their setters, and calls initialize while it builds the scheduler.
Under Spring Boot the raw Quartz properties go into spring.quartz.properties, which Boot passes to the
SchedulerFactoryBean. Plugin properties are ordinary Quartz properties, so this is the one and only place for them
(Boot ignores a quartz.properties file on the classpath; the measurement is on
Spring Boot Configuration). Values are strings: quote booleans and
numbers in YAML. This is the configuration of the four bundled plugins other than the shutdown hook, which a test run
loaded as written (the subsections below explain each one):
spring:
quartz:
properties:
org.quartz.plugin.jobHistory.class: org.quartz.plugins.history.LoggingJobHistoryPlugin
org.quartz.plugin.jobHistory.jobToBeFiredMessage: "Job {1}.{0} starts (trigger {4}.{3}, refire {7})"
org.quartz.plugin.triggerHistory.class: org.quartz.plugins.history.LoggingTriggerHistoryPlugin
org.quartz.plugin.jobInitializer.class: org.quartz.plugins.xml.XMLSchedulingDataProcessorPlugin
org.quartz.plugin.jobInitializer.fileNames: quartz-jobs.xml
org.quartz.plugin.jobInitializer.failOnFileNotFound: "true"
org.quartz.plugin.jobInitializer.scanInterval: "0"
org.quartz.plugin.interruptMonitor.class: org.quartz.plugins.interrupt.JobInterruptMonitorPlugin
org.quartz.plugin.interruptMonitor.defaultMaxRunTime: "60000"
Source: Quartz configuration: Plug-Ins
and Spring Boot: Quartz Scheduler. Run on Boot 4.1.1 with the
extra dependency noted in the XML subsection: the scheduler registered the trigger listeners [interruptMonitor,
triggerHistory] and the job listener [jobHistory], and loaded the job from the XML file.
The Quartz documentation page for plugins is partly stale: it describes the trigger-history plugin as logging through
"Jakarta Commons-Logging", while the 2.5.2 classes log through SLF4J (the logger names are the plugin class names, so the
levels are controlled with logging.level.org.quartz.plugins.history).
Plugin class (package org.quartz.plugins…) |
What it does | Use under Spring Boot |
|---|---|---|
|
Registers a |
Yes, for a cheap audit trail |
|
Registers a |
Yes |
|
Loads jobs and triggers from XML files when the scheduler starts, optionally re-scanning the files |
Rarely: Spring beans do the same job |
|
Registers a JVM shutdown hook that shuts the scheduler down |
No: Spring already shuts the scheduler down |
|
Interrupts jobs that run longer than a budget |
Only with care: see its flaw below |
Source: Quartz API: LoggingJobHistoryPlugin, LoggingTriggerHistoryPlugin, XMLSchedulingDataProcessorPlugin, ShutdownHookPlugin and JobInterruptMonitorPlugin.
The history plugins
LoggingJobHistoryPlugin and LoggingTriggerHistoryPlugin write a log line per event at INFO (a failed job is logged at
WARN). Their wording is configurable: the job plugin has jobToBeFiredMessage, jobSuccessMessage, jobFailedMessage
and jobWasVetoedMessage; the trigger plugin has triggerFiredMessage, triggerMisfiredMessage and
triggerCompleteMessage. Each is a java.text.MessageFormat pattern. The positional arguments, read from the 2.5.2
source, are:
| Plugin | Arguments |
|---|---|
|
|
|
|
Source: the history plugin sources at v2.5.2.
With the YAML above a test run logged these lines for the Bookshelf’s cacheRefresh job (each logger is the plugin
class, so the lines carry the plugin name in the log pattern):
LoggingTriggerHistoryPlugin : Trigger bookshelf.cacheRefreshTrigger fired job bookshelf.cacheRefresh at: 11:29:23 10/05/2026
LoggingJobHistoryPlugin : Job bookshelf.cacheRefresh starts (trigger bookshelf.cacheRefreshTrigger, refire 0)
LoggingJobHistoryPlugin : Job bookshelf.cacheRefresh execution complete at 11:29:23 10/05/2026 and reports: injected
LoggingTriggerHistoryPlugin : Trigger bookshelf.cacheRefreshTrigger completed firing job bookshelf.cacheRefresh at 11:29:23 10/05/2026 with resulting trigger instruction code: DO NOT
Source: measured in a test run on Quartz 2.5.2; see the history plugin sources at v2.5.2.
The line shows the cost of the plugins: they log at INFO for every execution of every job, and a job that fires every second produces three lines per second. They are excellent for development, a test environment or a low-volume scheduler, and they are a poor fit for a high-frequency scheduler; there the listener-based metrics of Observability are the better tool. Putting the job key into the log context of the job’s own log lines (MDC) is covered there too, together with the base logging setup of Logging.
XMLSchedulingDataProcessorPlugin
This plugin reads jobs and triggers from an XML file at scheduler start, the declarative alternative to defining
JobDetail and Trigger beans. Its properties are fileNames (comma-separated, default quartz_data.xml),
failOnFileNotFound (default true), scanInterval (in seconds, default 0, which means never re-scan) and
wrapInUserTransaction (default false). The Bookshelf’s catalogue-audit job as XML, validated against Quartz’s
job_scheduling_data_2_0.xsd:
<?xml version="1.0" encoding="UTF-8"?>
<job-scheduling-data xmlns="http://www.quartz-scheduler.org/xml/JobSchedulingData"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.quartz-scheduler.org/xml/JobSchedulingData http://www.quartz-scheduler.org/xml/job_scheduling_data_2_0.xsd"
version="2.0">
<schedule>
<job>
<name>catalogueAudit</name>
<group>bookshelf</group>
<description>Checks the catalogue for books without an ISBN</description>
<job-class>bookshelf.CatalogueAuditJob</job-class>
<durability>true</durability>
<recover>false</recover>
<job-data-map>
<entry>
<key>scope</key>
<value>missing-isbn</value>
</entry>
</job-data-map>
</job>
<trigger>
<cron>
<name>catalogueAuditTrigger</name>
<group>bookshelf</group>
<job-name>catalogueAudit</job-name>
<job-group>bookshelf</job-group>
<cron-expression>0 0 4 * * ?</cron-expression>
</cron>
</trigger>
</schedule>
</job-scheduling-data>
Source: Quartz cookbook: JobInitPlugin.
Run on Boot 4.1.1 with the cron expression replaced by 0/1 * * * * ? so that it fires within the test: the job was
added at startup and the Spring-created job instance logged its scope value every second.
Three facts matter in practice:
-
The plugin needs
jakarta.transaction-apion the classpath. The plugin extends a base class that referencesjakarta.transaction.UserTransaction, andspring-boot-starter-quartzdoes not bring that API. Without it the application fails at startup withjava.lang.ClassNotFoundException: jakarta.transaction.UserTransactionwhile creating thequartzSchedulerbean. Addjakarta.transaction:jakarta.transaction-api(its version is managed by Spring Boot) to fix it. The plugin class is part of Quartz, so Spring Boot 3.5 behaves the same, but this was only run on 4.1.1. -
Every start overwrites. The plugin processes the file with the default of replacing existing data: the processor stores each job with
replaceset totrue. On a persistent store, every node that starts re-saves the job from the file and so resets its storedJobDataMap; a job that keeps state with@PersistJobDataAfterExecutionloses that state at each restart. For the same reason a file-defined job and a bean-defined job with the same key fight over the definition. The Spring route,spring.quartz.overwrite-existing-jobs(defaultfalse), is the safer default. -
scanIntervalschedules a job. With a value above zero the plugin schedules its own file-watching job and trigger in the groupJobSchedulingDataLoaderPluginto re-read the file; on a clustered store that is one more job to account for.
The honest recommendation is that Spring beans (see Getting Started with Spring Boot) are better than XML for an application that already has a Spring context: they are type-checked, refactorable and can inject configuration. The XML plugin earns its place when the job definitions must be editable by operations without a build, or are shared with a non-Spring Quartz deployment.
ShutdownHookPlugin, and why not under Spring
ShutdownHookPlugin registers a JVM shutdown hook that calls scheduler.shutdown(cleanShutdown); its only property is
cleanShutdown (default true, which waits for running jobs to finish). It exists for plain Quartz applications, which
have nobody else to stop the scheduler when the JVM exits. Under Spring Boot somebody else already does: the
SchedulerFactoryBean shuts the scheduler down when the application context closes, honouring
spring.quartz.wait-for-jobs-to-complete-on-shutdown (default false). Adding the plugin adds a second, independent
shutdown path that races with Spring’s. In a test run:
-
Without the plugin, a
SIGTERMmade Spring close the context and shut the scheduler down at once (it logged "Shutting down Quartz Scheduler" and "shutdown complete" in the same millisecond) while an 8-second job was running: the settingwait-for-jobs-to-complete-on-shutdown=falsewas honoured. -
With the plugin, in 3 of 3 runs the plugin’s own thread (
Shutting down Quartz…) began the scheduler shutdown first, and Spring’sSchedulerFactoryBeanfollowed 5 to 15 milliseconds later in another thread. The plugin’scleanShutdown=truethen governed: the scheduler waited for the job instead of obeying Spring’s setting, while Spring went on closing the other beans, including the data source a JDBC job store is still using. -
After a normal
close()of the context the hook is still registered, and it ran again at JVM exit (Shutting down Quartz…) on an already stopped scheduler: harmless, but pure noise, and in a test suite that creates many contexts every one leaves a hook behind.
spring:
quartz:
wait-for-jobs-to-complete-on-shutdown: true # Spring's setting; do not add ShutdownHookPlugin
lifecycle:
timeout-per-shutdown-phase: 30s # how long Boot waits for the graceful phase
server:
shutdown: graceful
Source: Quartz API: ShutdownHookPlugin,
its source at v2.5.2
and Spring Boot: Quartz Scheduler. The SIGTERM comparison was
run on Boot 4.1.1 with a minimal test main and a RAM store; the interaction with a JDBC store’s data source was reasoned from
the thread order above, not run. Whether the YAML above is the best shutdown policy for a clustered deployment is
discussed on Clustering and
Production & Best Practices.
JobInterruptMonitorPlugin, and a flaw to know about
JobInterruptMonitorPlugin is a trigger listener that interrupts jobs which run for too long. It registers itself
through initialize, and for every trigger that fires it looks at the job detail’s JobDataMap (not the trigger’s, and
not the merged map): when AutoInterruptable is true it schedules an interrupt after MaxRunTime milliseconds from the
same map, falling back to the plugin’s defaultMaxRunTime property (default 300000, five minutes). The interrupt is
scheduler.interrupt(jobKey), which only works for jobs implementing InterruptableJob:
public class BulkImportJob implements Job, InterruptableJob {
private final CountDownLatch stop = new CountDownLatch(1);
@Override
public void execute(JobExecutionContext context) {
long workMs = context.getMergedJobDataMap().getLong("workMs");
try {
// stands in for "import the next batch" in a loop; returns early once interrupt() was called
boolean interrupted = stop.await(workMs, TimeUnit.MILLISECONDS);
log.info("bulk import {}", interrupted ? "INTERRUPTED" : "completed");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
@Override
public void interrupt() {
stop.countDown(); // the job must notice this and stop cleanly
}
}
JobDetail importJob = JobBuilder.newJob(BulkImportJob.class)
.withIdentity("catalogueImport", "bookshelf")
.usingJobData("AutoInterruptable", true)
.usingJobData("MaxRunTime", 800L) // milliseconds
.storeDurably()
.build();
Source: Quartz API: InterruptableJob and
JobInterruptMonitorPlugin at v2.5.2.
Run on Boot 4.1.1 with the interruptMonitor plugin from the YAML above. See
Jobs & Job Data for how a job should react to interruption.
Alone, the plugin works: a job needing 5 seconds with MaxRunTime 800 was interrupted after 806 ms. A job that is not an
InterruptableJob is not interrupted, and the monitor’s own log line says why (Error interrupting Job: Job
interrupt.importPlain can not be interrupted, since it does not implement org.quartz.InterruptableJob).
The plugin is unreliable when more than one job runs. In 2.5.2 the plugin keeps the scheduled interrupt in a single
instance field and every trigger completion cancels whatever that field holds, for any job, interruptible or not. So the
completion of an unrelated short job cancels the pending interrupt of a long one. In a test run, with the plugin
as the only plugin, a job with MaxRunTime 1500 and a different 50 ms job that finished at about 0.65 s: the long job
was never interrupted and ran its full 3005 ms. The same effect showed up by accident in the first experiment, where the
every-second XML job completing cancelled the monitor of the import job. The unreleased changes on Quartz’s main branch include a fix for a leak in this plugin
(issue 1349); whether it also changes this behaviour was not checked, and it is not in 2.5.2. Do not rely on the plugin in
a scheduler that runs anything concurrently.
The reliable replacement is a job listener that keeps one watchdog per execution, keyed by the unique fire instance id, and cancels only its own:
public class JobTimeoutListener extends JobListenerSupport implements AutoCloseable {
private static final Logger log = LoggerFactory.getLogger(JobTimeoutListener.class);
private final ScheduledExecutorService watchdog = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "job-timeout-watchdog");
thread.setDaemon(true);
return thread;
});
private final Map<String, ScheduledFuture<?>> running = new ConcurrentHashMap<>();
@Override
public String getName() {
return "bookshelf-job-timeout";
}
@Override
public void jobToBeExecuted(JobExecutionContext context) {
if (!context.getJobDetail().getJobDataMap().containsKey("maxRunMillis")) {
return; // this job opted out
}
long maxRunMillis = context.getJobDetail().getJobDataMap().getLong("maxRunMillis");
String fireInstanceId = context.getFireInstanceId(); // unique per execution
Scheduler scheduler = context.getScheduler();
running.put(fireInstanceId, watchdog.schedule(() -> interrupt(scheduler, context, fireInstanceId),
maxRunMillis, TimeUnit.MILLISECONDS));
}
@Override
public void jobWasExecuted(JobExecutionContext context, JobExecutionException jobException) {
ScheduledFuture<?> future = running.remove(context.getFireInstanceId());
if (future != null) {
future.cancel(false); // only this execution's watchdog
}
}
private void interrupt(Scheduler scheduler, JobExecutionContext context, String fireInstanceId) {
running.remove(fireInstanceId);
try {
log.warn("job {} exceeded its time budget, interrupting", context.getJobDetail().getKey());
scheduler.interrupt(fireInstanceId); // throws if the job is not an InterruptableJob
} catch (SchedulerException e) {
log.warn("job {} cannot be interrupted: {}", context.getJobDetail().getKey(), e.getMessage());
}
}
@Override
public void close() {
watchdog.shutdownNow();
}
}
@Bean(destroyMethod = "close")
JobTimeoutListener jobTimeoutListener() {
return new JobTimeoutListener();
}
Source: Quartz API: Scheduler (interrupt(String fireInstanceId))
and Quartz tutorial, lesson 7. Registration is
one of the three ways above. Run on Boot 4.1.1 with five jobs at once: a job with a budget of 800 ms was interrupted at
807 ms, one with 1500 ms at 1505 ms while an unrelated job completed in between, a job without maxRunMillis ran to
completion, and a job that is not an InterruptableJob produced the "cannot be interrupted" warning. As with the plugin,
the interrupt works in one JVM: Scheduler.interrupt is not cluster aware, so it can only stop an execution running on
the node that calls it, which is exactly the node whose listener started the watchdog.
Writing your own plugin
When listeners should exist in every deployment without Spring wiring, or a component needs the scheduler’s lifecycle,
implement SchedulerPlugin and name it in the properties. Bean properties are set from the property group through
setters. This plugin registers the audit listener, and can be switched off with a property:
public class AuditPlugin implements SchedulerPlugin {
private boolean enabled = true; // set from org.quartz.plugin.audit.enabled
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
@Override
public void initialize(String name, Scheduler scheduler, ClassLoadHelper loadHelper) throws SchedulerException {
if (enabled) {
scheduler.getListenerManager().addJobListener(new JobAuditListener());
}
}
@Override
public void start() {
// called when the scheduler starts
}
@Override
public void shutdown() {
// called when the scheduler shuts down
}
}
Source: Quartz API: SchedulerPlugin.
spring:
quartz:
properties:
org.quartz.plugin.audit.class: bookshelf.AuditPlugin
org.quartz.plugin.audit.enabled: "true"
Source: Quartz API: SchedulerPlugin and
Quartz configuration: Plug-Ins.
Run on Boot 4.1.1: the scheduler reported the job listener bookshelf-job-audit and a one-shot job produced the audit
line. A plugin is instantiated by Quartz, not by Spring, so it cannot take constructor injection of Spring beans; it can
reach the application through the Scheduler it is given (scheduler.getContext() holds what was put in with
SchedulerFactoryBean.setSchedulerContextAsMap). A listener that needs beans is simpler as a Spring bean registered
through way 1 or way 2.
Best practices
-
Keep listeners fast. Job and trigger listeners run on the worker thread that is about to run the job, and a trigger listener’s
vetoJobExecutiondelays it. Do nothing slower than updating a counter or writing one log line; hand real work (an HTTP call to an alerting system, a database write) to an executor. -
Never let a listener throw. A
triggerFired,vetoJobExecutionorjobToBeExecutedthat throws silently stops the job (see the table above). CatchExceptioninside the callback, log it, and decide explicitly what the fallback is (for a veto: do not veto). -
Alert on
schedulerError. OneSchedulerListenerthat logs at ERROR or pages someone turns every swallowed listener failure into a signal. -
Return from
vetoJobExecutionquickly and remember that a vetoed tick is gone. Design the job so that the next run catches up, as the overdue-loan reminders do. -
Register listeners at startup, on every node. The registrations live in memory. Use the customizer or the
SmartInitializingSingletonso that they exist before the scheduler starts. -
Keep registration in one place. The
SchedulerFactoryBeansetters replace; two customizers silently override each other. Collect the beans, or use theListenerManagerfor everything with a matcher. -
Name listeners uniquely and deliberately. The name is the registration key: a second listener with the same name replaces the first, and the matcher operations address listeners by name.
-
Prefer matchers over
ifstatements inside a listener. A listener narrowed byGroupMatcheris not even called for other jobs, and the intention is visible where it is registered. -
Do not put business logic in listeners. A listener is an observer. A job that must happen "after job A" is a
JobChainingJobListenerat most; anything more elaborate belongs in a workflow or in the job itself. -
Use the history plugins sparingly. They log every execution at INFO.
-
Do not add
ShutdownHookPluginunder Spring, and do not trustJobInterruptMonitorPluginwith concurrent jobs.
| Pitfall | Consequence and fix |
|---|---|
A listener is declared as a bean and nothing else |
Boot does not register it. Hand it to a |
Two customizers call |
Only the last call wins. One customizer, or collect the beans with |
A |
The job silently does not run on that tick, every tick, and the only trace is |
The maintenance veto is a flag in one node’s memory |
Other nodes keep running the jobs. Use a shared flag when the window is cluster-wide. |
|
|
The XML plugin on a persistent store |
Each node start overwrites the job definition and resets its stored |
|
Two shutdown paths race and the plugin’s |
|
The completion of any job cancels the pending interrupt of the last monitored one. Use a per-execution listener. |
Plugin properties in a classpath |
Ignored by Spring Boot. Put them in |