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.

Table 1. The listener interfaces and what each observes
Interface Observes Callbacks

JobListener

One execution of a job

getName(), jobToBeExecuted(context), jobExecutionVetoed(context), jobWasExecuted(context, jobException)

TriggerListener

One firing of a trigger, plus misfires

getName(), triggerFired(trigger, context), vetoJobExecution(trigger, context), triggerMisfired(trigger), triggerComplete(trigger, context, instruction)

SchedulerListener

Scheduler-wide events and changes to jobs and triggers

jobScheduled, jobUnscheduled, triggerFinalized, triggerPaused, triggersPaused, triggerResumed, triggersResumed, jobAdded, jobDeleted, jobPaused, jobsPaused, jobResumed, jobsResumed, schedulerError, schedulerInStandbyMode, schedulerStarting, schedulerStarted, schedulerShuttingdown, schedulerShutdown, schedulingDataCleared

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

sequenceDiagram autonumber participant App as Application code participant S as Scheduler participant T as TriggerListener participant J as JobListener participant Job as Job participant SL as SchedulerListener App->>S: scheduleJob(job, trigger) S-->>SL: jobScheduled(trigger) Note over S: the trigger becomes due, a worker thread takes over S->>T: triggerFired(trigger, context) S->>T: vetoJobExecution(trigger, context) alt a trigger listener returns true S->>J: jobExecutionVetoed(context) Note over S,J: the job does not run, triggerComplete is not called else nobody vetoes S->>J: jobToBeExecuted(context) S->>Job: execute(context) Job-->>S: returns or throws JobExecutionException S->>J: jobWasExecuted(context, jobException) S->>T: triggerComplete(trigger, context, instruction) opt the trigger has no next fire time S-->>SL: triggerFinalized(trigger) end opt the job is not durable and has no triggers left S-->>SL: jobDeleted(jobKey) end end
Figure 1. Callbacks around one execution, in the order measured on Quartz 2.5.2

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:

A JobListener that audits every execution
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());
        }
    }
}

With the listener registered (see below) every execution produces one line. These are the lines from a test run:

Output of JobAuditListener
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.

A TriggerListener that skips executions during a maintenance window
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
    }
}

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.

  • vetoJobExecution runs 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.

A SchedulerListener for errors, finished triggers and unscheduled jobs
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");
    }
}

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

Table 2. The ListenerManager operations
Method Purpose

addJobListener(listener), addJobListener(listener, matcher), addJobListener(listener, matchers…​), addJobListener(listener, List)

Register a job listener, optionally with matchers

addJobListenerMatcher(name, matcher), removeJobListenerMatcher, setJobListenerMatchers, getJobListenerMatchers(name)

Change the matchers of an already registered listener, addressed by its name

removeJobListener(name), getJobListeners(), getJobListener(name)

Remove or look up job listeners

addTriggerListener(…​) and the same family for triggers

The same operations, with Matcher<TriggerKey>

addSchedulerListener(listener), removeSchedulerListener(listener), getSchedulerListeners()

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

Table 3. The matchers in org.quartz.impl.matchers
Matcher Matches

EverythingMatcher.allJobs(), EverythingMatcher.allTriggers()

Every key; the default when no matcher is given

KeyMatcher.keyEquals(key)

One exact JobKey or TriggerKey

GroupMatcher.jobGroupEquals(g), jobGroupStartsWith, jobGroupEndsWith, jobGroupContains, and the trigger…​ variants, anyJobGroup()

Keys by group name

NameMatcher.jobNameEquals(n), jobNameStartsWith, jobNameEndsWith, jobNameContains, and the trigger…​ variants

Keys by name, in any group

AndMatcher.and(a, b), OrMatcher.or(a, b), NotMatcher.not(m)

Boolean combinations of other matchers

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:

Registering listeners with matchers
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:

Global listeners through SchedulerFactoryBeanCustomizer
@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:

One customizer that registers every listener bean
@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:

A listener with a matcher, registered before the first fire
@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:

Table 4. Effect of an exception thrown by a listener (Quartz 2.5.2, one callback at a time)
Callback that throws Job executed? Observed

triggerFired

No

0 executions in 2.2 s. schedulerError reported "Unable to notify TriggerListener(s) while firing trigger (Trigger and Job will NOT be fired!)". The trigger stayed NORMAL and tried again at every tick.

jobToBeExecuted

No

0 executions. schedulerError reported "Unable to notify JobListener(s) of Job to be executed: (Job will NOT be executed!)". The trigger stayed NORMAL.

jobWasExecuted

Yes

5 executions. schedulerError reported "(error will be ignored)". The job was not affected.

triggerComplete

Yes

5 executions. schedulerError reported "(error will be ignored)".

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

application.yml with the bundled plugins other than the shutdown hook
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).

Table 5. The bundled plugins at a glance
Plugin class (package org.quartz.plugins…​) What it does Use under Spring Boot

history.LoggingJobHistoryPlugin

Registers a JobListener for all jobs that logs fired, completed, failed and vetoed executions

Yes, for a cheap audit trail

history.LoggingTriggerHistoryPlugin

Registers a TriggerListener for all triggers that logs fired, misfired and completed triggers

Yes

xml.XMLSchedulingDataProcessorPlugin

Loads jobs and triggers from XML files when the scheduler starts, optionally re-scanning the files

Rarely: Spring beans do the same job

management.ShutdownHookPlugin

Registers a JVM shutdown hook that shuts the scheduler down

No: Spring already shuts the scheduler down

interrupt.JobInterruptMonitorPlugin

Interrupts jobs that run longer than a budget

Only with care: see its flaw below

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:

Table 6. MessageFormat arguments of the history plugins
Plugin Arguments

LoggingJobHistoryPlugin

{0} job name, {1} job group, {2} now, {3} trigger name, {4} trigger group, {5} previous fire time, {6} next fire time, {7} refire count, {8} result (success) or exception message (failure)

LoggingTriggerHistoryPlugin

{0} trigger name, {1} trigger group, {2} previous fire time, {3} next fire time, {4} now, {5} job name, {6} job group, {7} refire count, {8} instruction code, {9} instruction text (triggerCompleteMessage only)

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

Output of the two history plugins
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:

quartz-jobs.xml: a job and a cron trigger
<?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-api on the classpath. The plugin extends a base class that references jakarta.transaction.UserTransaction, and spring-boot-starter-quartz does not bring that API. Without it the application fails at startup with java.lang.ClassNotFoundException: jakarta.transaction.UserTransaction while creating the quartzScheduler bean. Add jakarta.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 replace set to true. On a persistent store, every node that starts re-saves the job from the file and so resets its stored JobDataMap; a job that keeps state with @PersistJobDataAfterExecution loses 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 (default false), is the safer default.

  • scanInterval schedules a job. With a value above zero the plugin schedules its own file-watching job and trigger in the group JobSchedulingDataLoaderPlugin to 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 SIGTERM made 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 setting wait-for-jobs-to-complete-on-shutdown=false was honoured.

  • With the plugin, in 3 of 3 runs the plugin’s own thread (Shutting down Quartz…​) began the scheduler shutdown first, and Spring’s SchedulerFactoryBean followed 5 to 15 milliseconds later in another thread. The plugin’s cleanShutdown=true then 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.

Shutdown under Spring: leave it to the SchedulerFactoryBean
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:

A job that can be asked to stop
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:

A per-execution timeout listener instead of the plugin
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:

A custom plugin and its configuration
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
    }
}
Properties that activate the custom plugin
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 vetoJobExecution delays 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, vetoJobExecution or jobToBeExecuted that throws silently stops the job (see the table above). Catch Exception inside the callback, log it, and decide explicitly what the fallback is (for a veto: do not veto).

  • Alert on schedulerError. One SchedulerListener that logs at ERROR or pages someone turns every swallowed listener failure into a signal.

  • Return from vetoJobExecution quickly 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 SmartInitializingSingleton so that they exist before the scheduler starts.

  • Keep registration in one place. The SchedulerFactoryBean setters replace; two customizers silently override each other. Collect the beans, or use the ListenerManager for 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 if statements inside a listener. A listener narrowed by GroupMatcher is 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 JobChainingJobListener at 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 ShutdownHookPlugin under Spring, and do not trust JobInterruptMonitorPlugin with concurrent jobs.

Table 7. Pitfalls on this page
Pitfall Consequence and fix

A listener is declared as a bean and nothing else

Boot does not register it. Hand it to a SchedulerFactoryBeanCustomizer or the ListenerManager.

Two customizers call setGlobalJobListeners

Only the last call wins. One customizer, or collect the beans with ObjectProvider.

A triggerFired or jobToBeExecuted implementation throws

The job silently does not run on that tick, every tick, and the only trace is schedulerError. Catch and log inside the listener.

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.

XMLSchedulingDataProcessorPlugin without jakarta.transaction-api

ClassNotFoundException: jakarta.transaction.UserTransaction at startup. Add the dependency.

The XML plugin on a persistent store

Each node start overwrites the job definition and resets its stored JobDataMap.

ShutdownHookPlugin next to Spring’s own shutdown

Two shutdown paths race and the plugin’s cleanShutdown overrides Spring’s wait setting. Remove the plugin.

JobInterruptMonitorPlugin with several jobs running

The completion of any job cancels the pending interrupt of the last monitored one. Use a per-execution listener.

Plugin properties in a classpath quartz.properties

Ignored by Spring Boot. Put them in spring.quartz.properties.