Jobs and Job Data

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.

A Quartz trigger decides when; the Job decides what, and almost every production incident in a Quartz-based service lives in the second half: a job that kept state in a field and lost it, data written into the wrong map and never stored, a job that ran twice after a node crash, a job that never returned. This page covers the job side of Quartz — how an instance is created and how long it lives, how JobDataMap is merged and persisted, the two concurrency annotations, durability, crash recovery, the ways a job can fail and what Quartz does about each, interrupting a running job, and the design rules (idempotency, transactions) that make re-execution harmless. It ends with launching a Spring Batch job from Quartz and with the quartz-jobs module.

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 (the outputs are quoted, not assumed), against the in-memory store, an in-memory H2 database for the JDBC store and, for crash recovery, PostgreSQL 17. On Spring Boot 3.5.16 the Quartz side is the same — Boot 3.5 also manages Quartz 2.5.2, and QuartzJobBean and SpringBeanJobFactory live in the same org.springframework.scheduling.quartz package of Spring Framework 6.2.19. The one real difference is the Spring Batch snippet, which targets Spring Batch 6 (Boot 4.1); Boot 3.5 manages Spring Batch 5.2.x, with other package names (see Launching a Spring Batch job from Quartz). The scheduler, trigger and store concepts used here are introduced in Quartz Fundamentals; the first Spring Boot job is built in Getting Started with Spring Boot.

The running Bookshelf scenario provides the jobs: the nightly overdue-loan reminders, the weekly statistics report, the monthly retention cleanup, the cache refresh and the per-member reminder.

The job instance lifecycle

Job has one method, execute(JobExecutionContext). What matters is who creates the instance and when. Quartz does not keep a job object between runs: the JobRunShell of every execution asks the scheduler’s JobFactory for a new job instance, calls execute on it, and drops it. Instance fields therefore start empty on every firing and nothing stored in them survives to the next one — state that must survive belongs in the JobDataMap (next section) or in your own database.

The one exception is a refire: when a job asks to be re-executed immediately (see JobExecutionException and its flags), the shell calls execute again on the same instance. The following job counts calls in a field and asks for exactly one refire:

A job that counts its own calls in an instance field
public class InstanceJob implements Job {

    int counter;                       // an instance field: 0 on every fresh instance

    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        counter++;
        System.out.println("instance=" + Integer.toHexString(System.identityHashCode(this))
                + " counter=" + counter + " refireCount=" + context.getRefireCount());
        if (context.getRefireCount() == 0) {
            throw new JobExecutionException("try again", true);   // refireImmediately
        }
    }
}

Scheduled three times 400 ms apart, the output was:

Table 1. Three firings, each with one refire (measured)
Firing Instance counter getRefireCount()

1, first call

94f9e87

1

0

1, refire

94f9e87 (same)

2

1

2, first call

35fbf3d3 (new)

1

0

2, refire

35fbf3d3 (same)

2

1

3, first call

7694bd65 (new)

1

0

3, refire

7694bd65 (same)

2

1

Because every firing gets a new instance, a job class needs no thread-safety for its own fields. It does not follow that the class may be stateless in the world: two firings of the same JobDetail can run on two threads at once unless @DisallowConcurrentExecution forbids it, so shared resources the job touches still need to be safe.

How Spring creates the instance

With Spring Boot the factory is Spring’s SpringBeanJobFactory, created with the application context. For each firing it calls createBean(jobClass) on the context’s autowire-capable bean factory: the job is a prototype-style object built by Spring, not a singleton bean. Constructor injection and @Autowired work; the class does not need @Component and should not be one. For a job class that is not a QuartzJobBean, Spring also applies the scheduler context, the job-level map and the trigger-level map as bean properties; QuartzJobBean does the same itself, inside execute, from the scheduler context and the merged map.

Only job data arrives through setters. A bean such as a service does not: a setter on a QuartzJobBean is called only if the key exists in the job data (or the scheduler context), and Spring does not autowire plain setters. A setter-only job therefore runs with a null collaborator, which was measured:

Setter injection of a service silently yields null (measured)
public class SetterOnlyJob extends QuartzJobBean {

    private ReminderService reminders;                       // no @Autowired, no constructor argument

    public void setReminders(ReminderService reminders) { this.reminders = reminders; }

    @Override
    protected void executeInternal(JobExecutionContext context) {
        System.out.println("reminders=" + reminders);        // prints: reminders=null
    }
}

Use constructor injection for collaborators and setters (or the merged map directly) for data. How job dependencies are wired is shown in detail, with a figure, in Getting Started with Spring Boot.

JobDataMap and the merged map

A JobDataMap is a Map<String, Object> with typed getters, and a job can find one in two places that Quartz merges before the job runs:

  • the map on the JobDetail, set with JobBuilder.usingJobData(…​) — data that belongs to the job;

  • the map on the Trigger, set with TriggerBuilder.usingJobData(…​) — data that belongs to one schedule of the job. An ad-hoc run through Scheduler.triggerJob(JobKey, JobDataMap) carries its map the same way.

The merged map is built by the execution context: a new JobDataMap receives everything from the JobDetail map and then everything from the Trigger map, so on an equal key the trigger value wins. getMergedJobDataMap() returns that copy.

The snippets assume static imports of JobBuilder.newJob, TriggerBuilder.newTrigger, SimpleScheduleBuilder.simpleSchedule and CronScheduleBuilder.cronSchedule. The Bookshelf overdue-loan job keeps a default batch size of 200 on the JobDetail and overrides it to 50 on its trigger. It also counts its runs:

Reading the merged map and writing the stored map
@PersistJobDataAfterExecution
@DisallowConcurrentExecution
public class OverdueLoanRemindersJob implements Job {

    @Override
    public void execute(JobExecutionContext context) {
        JobDataMap merged = context.getMergedJobDataMap();            // job data + trigger data, trigger wins
        int batchSize = merged.getInt("batchSize");                   // 50, not 200
        String template = merged.getString("template");               // only on the JobDetail

        JobDataMap stored = context.getJobDetail().getJobDataMap();   // the map Quartz stores
        stored.put("runs", stored.getInt("runs") + 1);                // persisted after execution
        merged.put("lastBatchSize", batchSize);                       // lost: the merged map is a copy
    }
}
Registering the job with job data and the trigger with its override
JobDetail job = newJob(OverdueLoanRemindersJob.class).withIdentity("overdueLoanReminders", "bookshelf")
        .usingJobData("batchSize", 200)
        .usingJobData("template", "overdue-default")
        .usingJobData("runs", 0)
        .storeDurably().build();

Trigger trigger = newTrigger().withIdentity("overdueLoanRemindersTrigger", "bookshelf").forJob(job)
        .usingJobData("batchSize", 50)
        .startNow()
        .withSchedule(simpleSchedule().withIntervalInMilliseconds(300).withRepeatCount(2)).build();

scheduler.scheduleJob(job, trigger);

Run with three firings, the job saw the same merged values every time, except for the counter that comes back from the stored map:

Table 2. What the job saw and what was stored (measured, in-memory store)
Moment Content

merged map, run 1

batchSize=50, template=overdue-default, runs=0

merged map, run 2

batchSize=50, template=overdue-default, runs=1

merged map, run 3

batchSize=50, template=overdue-default, runs=2

JobDetail map stored afterwards

batchSize=200, runs=3, template=overdue-default — there is no lastBatchSize

The figure shows the same flow: two maps merge into a copy, and only a write to the JobDetail map can come back.

flowchart LR subgraph store["Job store"] JD["JobDetail data
batchSize=200
template=overdue-default
runs=0"] TD["Trigger data
batchSize=50"] end JD -->|"copied first"| M TD -->|"copied second, wins on equal keys"| M M["Merged map, a new copy per firing
batchSize=50, template=overdue-default, runs=0
context.getMergedJobDataMap()"] M --> J["Job instance
reads batchSize and template
QuartzJobBean also uses it for setters"] J -->|"put on the merged map"| L1(["discarded when the run ends"]) J -->|"put on context.getJobDetail().getJobDataMap()"| P{"@PersistJobDataAfterExecution
and the map changed?"} P -->|yes| S[("stored again
runs=1")] P -->|no| L2(["discarded, the next firing
starts from the stored map"]) S -.->|"next firing"| JD
Figure 1. How the merged JobDataMap is built and what is persisted after execution

What persists, and what does not

The rules, all visible in the stores' source and in the measurements above:

  • After a run, Quartz writes back only the map returned by context.getJobDetail().getJobDataMap(), and only if the job class carries @PersistJobDataAfterExecution and the map was changed (it is dirty). Writes to getMergedJobDataMap() are never stored.

  • Without the annotation, changes to the JobDetail map are lost too. A plain job that incremented a counter three times left the stored value at 0 in the same run; the annotated job left it at 3.

  • The trigger’s map is never written back. Per-run state therefore belongs on the JobDetail, and per-schedule configuration on the trigger.

  • @PersistJobDataAfterExecution applies to the class, and the stored map is per JobDetail. Quartz’s own Javadoc says such a job should seriously consider @DisallowConcurrentExecution as well, "to avoid data storage race conditions".

What to put in the map

Put only Strings and primitives (the usingJobData overloads accept String, Integer, Long, Float, Double and Boolean). The reason depends on the store:

  • With the in-memory store any object fits, but it is lost on restart.

  • With the JDBC store the map is stored as a serialized BLOB, so every value must be Serializable, and the serialized form is tied to your class: renaming or reshaping a stored class makes data already in the database unreadable. A non-serializable value is rejected when the job is stored; a Serializable record round-trips.

  • With org.quartz.jobStore.useProperties=true the map is stored as text instead, and every value must be a String; anything else is rejected. The putAsString and getIntFromString style helpers on JobDataMap exist for this mode.

Table 3. Rejections measured on the JDBC store (H2, Quartz 2.5.2)
Stored value Result

Serializable record MemberRef(7, "Ana"), default store

stored, read back as MemberRef[id=7, name=Ana]

new Thread(), default store

JobPersistenceException: Couldn’t store job: Unable to serialize JobDataMap for insertion into database because the value of property 'worker' is not serializable: java.lang.Thread

Integer or record with useProperties=true

JobPersistenceException: Couldn’t store job: JobDataMap values must be Strings when the 'useProperties' property is set. Key of offending value: batchSize

With Spring Boot an application that enables useProperties while its own JobDetail beans hold an Integer fails at startup while the quartzScheduler bean is created, with the message in the last row.

Keep secrets out of the map. The JDBC store keeps the map in a table anyone with database access can read, and the Spring Boot Actuator quartz endpoint shows job data masked as only while management.endpoint.quartz.show-values is at its default NEVER. Pass a reference (a secret name, a member id) and resolve the value from configuration inside the job. The mail job in the quartz-jobs section shows the opposite, a password parameter, and is a reason to write your own job instead.

@DisallowConcurrentExecution is per JobDetail

@DisallowConcurrentExecution forbids two executions of the same JobDetail at the same time. It is not a lock on the class: the unit is the job’s key (name and group). Two JobDetail`s of the same class run side by side, and one `JobDetail with two triggers that fire together is serialized, with the second trigger held back until the first run completes.

One class, two JobDetails, two triggers on the first
@DisallowConcurrentExecution
public class SerialJob implements Job {
    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException { /* takes 700 ms */ }
}

scheduler.addJob(newJob(SerialJob.class).withIdentity("A").storeDurably().build(), true);
scheduler.addJob(newJob(SerialJob.class).withIdentity("B").storeDurably().build(), true);
// triggers: ta1 and ta2 both on job A, tb1 on job B, each repeating every 100 ms

The highest number of simultaneous executions was measured as 1 for job A, 1 for job B and 2 across both, so the annotation serialized A’s two triggers but did not stop A and B from overlapping. A subclass of an annotated class is annotated too: JobDetail.isConcurrentExecutionDisallowed() returned true for a class that only extends SerialJob.

Two further points follow from the stores' source. While a non-concurrent job runs, its other triggers are moved to a BLOCKED state and released when it completes; with the JDBC store that state lives in the database, so the rule holds across the nodes of a cluster, not just within one JVM (see Clustering). And what a held-back trigger does when the wait is long — a misfire — is the subject of Misfires. The Spring-only alternative to this annotation, MethodInvokingJobDetailFactoryBean with concurrent=false, is covered in Getting Started with Spring Boot.

Durability

A JobDetail is durable when it is built with storeDurably(). A non-durable job exists only as long as it has triggers: when the last trigger completes or is removed, the store deletes the job. A durable job stays, with or without triggers, and can be triggered or given new triggers later.

A non-durable job vanishes with its last trigger; a durable one stays
// rejected: a non-durable job cannot be stored without a trigger
scheduler.addJob(newJob(PlainJob.class).withIdentity("nd").build(), true);
// SchedulerException: Jobs added with no trigger must be durable.

scheduler.scheduleJob(newJob(PlainJob.class).withIdentity("nd2").build(),
        newTrigger().withIdentity("t-nd2").startNow().build());                       // one-shot

scheduler.addJob(newJob(PlainJob.class).withIdentity("dur").storeDurably().build(), true);
scheduler.scheduleJob(newTrigger().withIdentity("t-dur").forJob("dur").startNow().build());
// after both one-shot triggers have fired:
//   scheduler.checkExists(JobKey.jobKey("nd2"))  -> false
//   scheduler.checkExists(JobKey.jobKey("dur"))  -> true

The rule of thumb is simple. The four fixed Bookshelf jobs, declared as JobDetail beans, are durable, so unscheduling or pausing their trigger never deletes them. The per-member reminder is created together with its single trigger and is not durable: once it has fired, it disappears and nothing needs cleaning up. How these beans are registered is covered in Getting Started with Spring Boot; creating and removing jobs at run time is covered in Dynamic Scheduling and Management.

Recovery after a crash: requestsRecovery and isRecovering

If the JVM dies in the middle of a job, a persistent store still has a row saying "this trigger was firing". What happens when the scheduler (or, in a cluster, another node) discovers that depends on one JobDetail flag:

  • Without requestRecovery(), the lost execution is simply forgotten. The trigger continues at its next fire time (or is handled as a misfire, see Misfires).

  • With requestRecovery(), Quartz creates a one-shot recovery trigger in the group RECOVERING_JOBS, named recover_<instance id>_<n>, scheduled for the original scheduled fire time. It carries a copy of the original trigger’s job data plus four extra keys that identify the original trigger and its fire times. The job runs again, and JobExecutionContext.isRecovering() returns true; getRecoveringTriggerKey() returns the original trigger’s key.

Recovery needs a persistent store: the in-memory store loses the record with the process, so requestRecovery() has an effect only with the JDBC store (or a store that persists its fired-trigger records).

Asking for recovery, and a job that knows it is being recovered
JobDetail weekly = newJob(WeeklyStatisticsJob.class).withIdentity("weeklyStatistics", "bookshelf")
        .requestRecovery()
        .storeDurably().build();

public class WeeklyStatisticsJob implements Job {

    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        // the week comes from the clock: a recovery keeps the original getScheduledFireTime(), but a misfire
        // handled with FIRE_ONCE_NOW rewrites it to the time of the late run (see Misfires)
        LocalDate week = LocalDate.now(ZoneId.of("Europe/Madrid"))
                .with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
        if (context.isRecovering()) {
            TriggerKey original = context.getRecoveringTriggerKey();
            // the previous attempt died part-way: the report below overwrites the row for this week,
            // so running it again from the start is safe
        }
        // ... compute and upsert the statistics row keyed by "week"
    }
}

The failover test of the verification run killed (kill -9) a node that was running a 25-second job with requestRecovery=true, while a second node sat in standby. QRTZ_FIRED_TRIGGERS showed the job as EXECUTING with requests_recovery=true; after the second node was resumed it re-executed the job with isRecovering() true 11.2 seconds after the kill, which is the configured check-in interval (2 s) plus the fixed 7.5 s grace plus one check-in cycle. How a node is declared dead is described in Clustering.

Recovery means "run it again from the start", never "continue where it stopped". The job must be written for that, which is the section on idempotent design below.

JobExecutionException and its flags

Job.execute may throw only JobExecutionException; it is Quartz’s channel from the job to the scheduler. Quartz logs it at INFO, tells the job listeners (jobWasExecuted(context, exception)) and the trigger, and carries on: a failing run does not stop the schedule. Three booleans decide what else happens:

Table 4. The flags of JobExecutionException (measured, in-memory store)
What the job does Effect Measured

throws new JobExecutionException("msg", false) or any plain one

logged; the trigger fires again at its next time

trigger stayed NORMAL, 7 runs in 1.6 s on a 250 ms repeat

throws an unchecked exception (IllegalStateException)

logged at ERROR, wrapped by Quartz in a JobExecutionException, scheduler listeners get schedulerError; the trigger fires again

trigger stayed NORMAL, 7 runs

setRefireImmediately(true) (or the constructor argument)

execute is called again at once, on the same instance, with getRefireCount() incremented

see the example below

setUnscheduleFiringTrigger(true)

the trigger that fired this run is set to COMPLETE; the job’s other triggers are untouched

trigger 1 COMPLETE after one run, trigger 2 stayed NORMAL

setUnscheduleAllTriggers(true)

every trigger of the job is set to COMPLETE

both triggers COMPLETE after one run

The word "unschedule" is slightly misleading: the triggers are marked complete, not deleted, so they remain visible in the store and in /actuator/quartz until removed.

Refiring is the flag to use with care. It re-runs the job immediately, in the same worker thread, and Quartz applies no limit to the number of refires: a job that always asks for one loops forever. It is safe only when something changes between attempts. The pattern of the official Example 6 (a job that fixes its data and asks to run again) reads like this for the Bookshelf export:

Adapting and refiring, with the adapted value kept for the next firing
@DisallowConcurrentExecution
@PersistJobDataAfterExecution
public class ExportJob implements Job {

    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        JobDataMap data = context.getJobDetail().getJobDataMap();   // the stored map, not the merged copy
        int batchSize = data.getInt("batchSize");
        try {
            if (batchSize > 100) throw new IllegalStateException("payload too large");
            // ... export one batch
        } catch (IllegalStateException e) {
            data.put("batchSize", batchSize / 2);                    // adapt ...
            throw new JobExecutionException("batch too large, retrying smaller", e, true);   // ... and run again now
        }
    }
}

Started with batchSize=500, the job ran four times in a row (500, 250, 125 and 62, getRefireCount() 0 to 3) and the stored value afterwards was 62. The job reads the stored map on purpose: during a refire the merged map is not rebuilt, so a change made to it would not be seen, whereas the JobDetail map is the same object on every attempt. The other official example, BadJob2, sets setUnscheduleAllTriggers(true) so that a job that can never succeed stops being scheduled.

The practice that follows:

  • Catch what you can handle, log with context (the member, the loan, the week), and rethrow as JobExecutionException with the cause and false, so listeners and metrics see a failure while the schedule continues. A swallowed exception looks like a success to every listener.

  • Use setUnscheduleFiringTrigger or setUnscheduleAllTriggers for failures that retrying cannot fix (a deleted member, a missing template), never as the general error handler.

  • Do not use refire loops to retry a flaky remote call. A retry with spacing, jitter and a cap is a different tool; the Resilience section covers it in Resilience, and Retry is the page to apply inside a job.

Interrupting a job

Quartz cannot stop a running job; it can only ask. A job opts in by implementing InterruptableJob, whose interrupt() method Quartz calls from another thread. What the method does is up to the job: set a flag that the loop checks, or interrupt the thread that runs the job.

The flag variant, checked between units of work
public class StoppableJob implements InterruptableJob {

    private volatile boolean stop;

    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        while (!stop && hasMoreWork()) {
            processNextBatch();
        }
    }

    @Override
    public void interrupt() { stop = true; }
}

When the work blocks (a sleep, a socket, a queue), the flag is not enough, and the job can keep a reference to its own thread and interrupt it. The weekly statistics report of the Bookshelf pages through the catalogue, 100 ms a page:

The thread variant, as a Spring QuartzJobBean
@DisallowConcurrentExecution
public class WeeklyStatisticsJob extends QuartzJobBean implements InterruptableJob {

    private volatile Thread runner;

    @Override
    protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
        runner = Thread.currentThread();
        try {
            for (int page = 1; page <= 100; page++) {
                Thread.sleep(100);                                   // one page of the report
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();                      // restore the flag for Quartz's worker
            throw new JobExecutionException("report interrupted", e, false);
        } finally {
            runner = null;                                           // never leave a stale thread behind
        }
    }

    @Override
    public void interrupt() {
        Thread t = runner;
        if (t != null) t.interrupt();
    }
}

Scheduler.interrupt

Scheduler.interrupt(JobKey) calls interrupt() on every currently executing instance of that job on this scheduler; its Javadoc states that it is not cluster aware. Scheduler.interrupt(String fireInstanceId) targets one execution; the id comes from JobExecutionContext.getFireInstanceId() or getCurrentlyExecutingJobs().

Table 5. Results of the interrupt calls (measured)
Call Result

scheduler.interrupt(JobKey.jobKey("manual")) while the job runs

true; the report above left after 505 ms with the interrupt delivered

scheduler.interrupt("nope") (no such fire instance)

false

scheduler.interrupt(JobKey.jobKey("nothing")) (nothing running)

false

interrupt on a job that is not an InterruptableJob

UnableToInterruptJobException: Job DEFAULT.plainSleep can not be interrupted, since it does not implement org.quartz.InterruptableJob

Shutdown has its own, off-by-default switches: with org.quartz.scheduler.interruptJobsOnShutdown=true the scheduler calls interrupt() on every executing InterruptableJob when shutdown(false) is called, and with org.quartz.scheduler.interruptJobsOnShutdownWithWait=true it does so before waiting when shutdown(true) is called. Under Spring Boot spring.quartz.wait-for-jobs-to-complete-on-shutdown (default false) chooses which of the two shutdown calls is made.

JobInterruptMonitorPlugin

org.quartz.plugins.interrupt.JobInterruptMonitorPlugin interrupts jobs that run too long. It is a trigger listener: when a job fires and its JobDetail data holds AutoInterruptable=true, it schedules a monitor that calls scheduler.interrupt(jobKey) after MaxRunTime milliseconds (job data, a long), or after the plugin’s defaultMaxRunTime (default 300000, five minutes). The job must still be an InterruptableJob; the plugin only supplies the stopwatch.

Enabling the plugin through Spring Boot properties
spring:
  quartz:
    properties:
      org.quartz.plugin.jobInterrupt.class: org.quartz.plugins.interrupt.JobInterruptMonitorPlugin
      org.quartz.plugin.jobInterrupt.defaultMaxRunTime: "300000"
Marking a job as auto-interruptible
JobDetail weekly = newJob(WeeklyStatisticsJob.class).withIdentity("weeklyStatistics", "bookshelf")
        .usingJobData(JobInterruptMonitorPlugin.AUTO_INTERRUPTIBLE, true)       // "AutoInterruptable"
        .usingJobData(JobInterruptMonitorPlugin.MAX_RUN_TIME, 600_000L)         // "MaxRunTime", milliseconds
        .storeDurably().build();

With the properties above and defaultMaxRunTime=1000, the report job of the previous section started with AutoInterruptable=true was interrupted after 1005 ms. A job without the key is simply not monitored and nothing fails.

The plugin has one limitation that matters in practice. It keeps a single future field for the monitor it scheduled last, and triggerComplete cancels that field whenever any job completes — including a job that was never monitored. With one auto-interruptible job at a time it works. With several overlapping, a job that finishes early cancels the monitor of the job that fired after it, and that one then runs unbounded. Both outcomes were measured, in two separate runs:

Table 6. Overlapping auto-interruptible jobs (measured)
Setup Outcome

auto-500 (MaxRunTime=500) and auto-default (default 1500 ms) fired together

auto-500 was interrupted after 539 ms; auto-default ran its full 5040 ms uninterrupted

short (300 ms) fired, 80 ms later long-800 (MaxRunTime=800, 5 s of work)

short finished at about 300 ms and cancelled the monitor; long-800 ran its full 5034 ms

The safe options are to give one scheduler at most one auto-interruptible job running at a time, or to enforce the deadline inside the job (compare System.nanoTime() against a deadline in the work loop, or keep the stopwatch in a JobListener), which needs no plugin at all.

Idempotent job design

A Quartz job can run more than once for the same logical occasion, and a correct service assumes it will:

Table 7. Why one occasion can produce several executions
Source of re-execution Why

refire (JobExecutionException with refireImmediately)

the job asked for it

recovery (requestRecovery)

the node died mid-run and the job runs again from the start

misfire policy FIRE_ONCE_NOW and its relatives

a missed run is executed late, possibly next to a regular one (see Misfires)

cluster failover

another node takes over after a crash or a stalled check-in

a manual run through triggerJob, or the Actuator POST on /actuator/quartz/jobs/{group}/{name}

someone triggered it by hand

The remedy is the same everywhere: make "doing it again" a no-op. The techniques, in the order to prefer them:

  • Key the work on the occasion, not on "now". getScheduledFireTime() is the time the trigger was meant to fire; it is the same for a refire and a recovery (the recovery trigger keeps the original scheduled time), whereas the wall clock is different every time. After a misfire handled with FIRE_ONCE_NOW or FIRE_NOW, however, it is the time of the late run, not the missed one (measured on Misfires), so derive the business period from the business data (the loans that are overdue, the week not yet reported) or from getPreviousFireTime(), not from the scheduled fire time alone. The reminder for 5 October is the reminder for 5 October whichever run produces it.

  • Claim before acting, with a unique constraint. Insert a row for the occasion (loan and day) and act only when the insert succeeds.

  • Prefer upserts and natural keys to blind inserts and counters, so the second run overwrites with the same values.

  • Let the downstream system deduplicate when it can (a mail provider’s idempotency key, a Spring Batch JobInstance, see below).

The overdue-loan reminders as a small transactional service with a claim table:

Claiming each reminder once, in one transaction
@Service
public class ReminderService {

    private final JdbcClient jdbc;

    public ReminderService(JdbcClient jdbc) { this.jdbc = jdbc; }

    /** Claims each overdue loan for the day exactly once and returns how many were new. */
    @Transactional
    public int remindOverdue(LocalDate day, List<Long> overdueLoanIds) {
        int sent = 0;
        for (Long id : overdueLoanIds) {
            int claimed = jdbc.sql("""
                    insert into reminder_log(loan_id, due_day)
                    select ?, ? where not exists (select 1 from reminder_log where loan_id = ? and due_day = ?)
                    """).params(id, day, id, day).update();
            if (claimed == 1) {
                sent++;                      // only a new claim sends a reminder
                // enqueue the e-mail for this loan here
            }
        }
        return sent;
    }
}

Two firings on the same day produced, in order, newReminders=5 (batch size 5 from the trigger) and then newReminders=0. The table held 5 rows and the counter of sent reminders stood at 5.

Calling transactional services from a job

A Quartz job runs on a Quartz worker thread, in no Spring transaction. Quartz’s own transaction (the one that updates trigger state in the JDBC store) is separate from anything the job does. The job therefore calls a @Transactional service method, which opens and commits its own transaction, and the job class itself stays free of @Transactional so that the transaction boundary is explicit and lives in one place.

A job that delegates the transactional work to a service (overdueLoanIds loads the ids and is omitted)
@PersistJobDataAfterExecution
@DisallowConcurrentExecution
public class OverdueLoanRemindersJob extends QuartzJobBean {

    private final ReminderService reminders;
    private int batchSize = 100;                                   // from the merged JobDataMap

    public OverdueLoanRemindersJob(ReminderService reminders) { this.reminders = reminders; }

    public void setBatchSize(int batchSize) { this.batchSize = batchSize; }

    @Override
    protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
        // the day of this run: after a FIRE_ONCE_NOW misfire it is the day of the late run, not the missed 02:00;
        // the job reads the loans overdue now, so one late run covers the missed nights (see Misfires)
        LocalDate day = context.getScheduledFireTime().toInstant()
                .atZone(ZoneId.of("Europe/Madrid")).toLocalDate();
        try {
            reminders.remindOverdue(day, overdueLoanIds(batchSize));
            JobDataMap stored = context.getJobDetail().getJobDataMap();
            stored.put("runs", stored.getInt("runs") + 1);
        } catch (RuntimeException e) {
            throw new JobExecutionException("overdue reminders failed", e, false);
        }
    }
}

A transaction protects database writes only. In the measured failure case the service claimed two new loans, handed their reminders to the (simulated) mail gateway, and then failed: the database rolled the claims back to the previous 3 rows, but the gateway counter had already advanced by 2 (from 5 to 7), so the next run sends those two mails again. External side effects are at-least-once; the claim table only prevents duplicate runs that complete. Where duplicate mails matter, write an outbox row in the same transaction and let a separate step dispatch it, or pass an idempotency key to the provider. The wider treatment of transactions is in Transaction Isolation and Locking.

Two consequences for the job store:

  • Scheduling calls made inside a Spring transaction join it when the JDBC store runs on the same DataSource (Boot’s LocalDataSourceJobStore uses Spring’s connection handling). Scheduling a per-member reminder in the same transaction that creates the member makes the two commit or roll back together, as measured below.

  • Boot passes the unique PlatformTransactionManager, or the bean marked @QuartzTransactionManager, to the scheduler factory, which uses it when it registers the JobDetail and Trigger beans at startup.

Scheduling inside a transaction (measured on H2 with the JDBC store)
tx.executeWithoutResult(status -> {
    scheduler.scheduleJob(newJob(MemberReminderJob.class).withIdentity("member-42-reminder").build(),
            newTrigger().withIdentity("member-42-trigger").startAt(inOneMinute).build());
    throw new IllegalStateException("business rule failed after scheduling");
});
// afterwards: scheduler.checkExists(JobKey.jobKey("member-42-reminder")) -> false (rolled back)
// the same call in a transaction that commits: the job exists

Launching a Spring Batch job from Quartz

Spring Batch does not schedule itself; a Quartz job is a good launcher for a weekly or nightly batch run. The job should be a thin adapter. Three rules make it robust: build the JobParameters from the scheduled fire time (so a refire, misfire or recovery maps to the same JobInstance), treat "already completed" as success, and mark the job @DisallowConcurrentExecution.

The weekly statistics batch job, launched by a Quartz job
@DisallowConcurrentExecution
public class WeeklyStatisticsBatchJob extends QuartzJobBean {

    private final JobOperator jobOperator;
    private final Job weeklyStatistics;

    public WeeklyStatisticsBatchJob(JobOperator jobOperator, @Qualifier("weeklyStatistics") Job weeklyStatistics) {
        this.jobOperator = jobOperator;
        this.weeklyStatistics = weeklyStatistics;
    }

    @Override
    protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
        // the Monday of this run's week, from the clock: a refire, a recovery and a late run after a misfire
        // all map to the same JobInstance, whereas getScheduledFireTime() is rewritten by a misfire (see Misfires)
        LocalDate runDate = LocalDate.now(ZoneId.of("Europe/Madrid"))
                .with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
        JobParameters parameters = new JobParametersBuilder().addLocalDate("run.date", runDate).toJobParameters();
        try {
            jobOperator.start(weeklyStatistics, parameters);
        } catch (JobInstanceAlreadyCompleteException e) {
            // this week's instance already completed: a refire, a recovery or a late run, nothing to do
        } catch (Exception e) {
            throw new JobExecutionException(e, false);
        }
    }
}

With the JDBC job repository (spring-boot-starter-batch-jdbc, here on H2) the first launch for 5 October completed (status=COMPLETED), and launching again for the same day completed without running the step: the exception was caught and reported as already completed. The launch is synchronous by default, so the Quartz worker thread is held for the whole batch run, which is one more reason for @DisallowConcurrentExecution.

The existing page Running a job covers JobOperator in full and has a shorter Quartz example, BatchQuartzJob, to which the rule about collaborators above applies: pass JobOperator and the Job through the constructor, or through the scheduler context, because plain setters are not autowired.

On Spring Boot 3.5 the same adapter uses Spring Batch 5.2.x: org.springframework.batch.core.Job, JobParameters, JobParametersBuilder and JobLauncher.run(job, parameters), with org.springframework.batch.core.repository.JobInstanceAlreadyCompleteException.

The quartz-jobs module

Quartz ships ready-made jobs in a second artifact, org.quartz-scheduler:quartz-jobs. Spring Boot manages its version together with quartz, so the dependency carries no version:

Adding quartz-jobs with Spring Boot’s managed version
<dependency>
    <groupId>org.quartz-scheduler</groupId>
    <artifactId>quartz-jobs</artifactId>
</dependency>

The jobs in 2.5.2, read from the module’s sources:

Table 8. The jobs in quartz-jobs 2.5.2
Class What it does

org.quartz.jobs.NoOpJob

does nothing; useful as a placeholder or in tests

org.quartz.jobs.DirectoryScanJob

scans a directory and calls a DirectoryScanListener with the new or changed files

org.quartz.jobs.FileScanJob

checks one file’s modification time and calls a FileScanListener

org.quartz.jobs.ee.mail.SendMailJob

sends an e-mail described entirely by job data (jakarta.mail)

org.quartz.jobs.ee.jmx.JMXInvokerJob

invokes a JMX operation named in the job data

org.quartz.jobs.ee.jms.SendQueueMessageJob, SendTopicMessageJob, SendDestinationMessageJob

send a JMS message

org.quartz.jobs.ee.ejb.EJBInvokerJob, EJB3InvokerJob

call an EJB method named in the job data

NativeJob, which ran an operating-system command described in the job data, is not in the module any more: Quartz removed it in 2.4.0 for security reasons. Comparing the artifacts confirms it: the quartz-jobs 2.3.2 jar contains org.quartz.jobs.NativeJob, and the 2.4.1 and 2.5.2 jars do not. Run an external command from your own job instead, with an explicit, fixed command line.

Three practical notes on the rest. The POM of quartz-jobs 2.5.2 declares only quartz and slf4j-api at runtime, so the mail, JMS, EJB and JMX libraries come from your application, and the 2.4.1 and 2.5.2 jars also contain copies of the core org.quartz classes (2.5.2: about 650 KB), harmless as long as both artifacts are the same version. SendMailJob reads the SMTP password from the job data, which puts a credential into the job store and, when values are shown, into the Actuator; in a Spring application use a job that calls JavaMailSender and reads its credentials from configuration. The invoker jobs run whatever the job data names, so write access to job data is write access to those calls; treat the store as a trusted component.

The scan jobs are the ones worth using. Both are annotated @DisallowConcurrentExecution and @PersistJobDataAfterExecution (the last-seen modification time is kept in the stored map), and both find their listener in the SchedulerContext by name:

Watching the Bookshelf catalogue import folder with DirectoryScanJob
scheduler.getContext().put("importListener", (DirectoryScanListener) files -> {
    for (File file : files) {
        importCatalogue(file);                                 // your code
    }
});

scheduler.scheduleJob(newJob(DirectoryScanJob.class).withIdentity("importScan")
                .usingJobData(DirectoryScanJob.DIRECTORY_NAME, "/var/bookshelf/import")
                .usingJobData(DirectoryScanJob.DIRECTORY_SCAN_LISTENER_NAME, "importListener")
                .usingJobData(DirectoryScanJob.MINIMUM_UPDATE_AGE, 1000L).build(),
        newTrigger().startNow()
                .withSchedule(simpleSchedule().withIntervalInMilliseconds(500).repeatForever()).build());

A file written into the folder was reported to the listener once it was older than MINIMUM_UPDATE_AGE, and its rewrite was reported again. FileScanJob accepts the same MINIMUM_UPDATE_AGE key, but it had no effect in the test: rewriting the watched file just before a scan, with the age set to 60000 ms, notified the listener immediately. Its source adds the minimum age to the current time instead of subtracting it, so only use FileScanJob for files that are not written in place, or prefer DirectoryScanJob. The listener runs on a Quartz worker thread, in no transaction, and the scan job re-runs after a crash: apply the idempotency rules above to importCatalogue.

Putting it together: the overdue-loan reminders

The configuration below ties the page together for Spring Boot: a durable job that asks for recovery, trigger-level data, a cron trigger with a fire-and-proceed misfire policy and an injectable cron expression. Run with the cron overridden to fire every two seconds, it fired twice, left the persisted counter at runs=2 and sent each reminder once; a manual triggerJob with batchSize=3 reused the claims and created nothing new.

The Bookshelf overdue-loan job and its trigger as Spring beans
@Configuration(proxyBeanMethods = false)
public class BookshelfJobs {

    @Bean
    JobDetail overdueLoanRemindersJob() {
        return newJob(OverdueLoanRemindersJob.class).withIdentity("overdueLoanReminders", "bookshelf")
                .usingJobData("batchSize", 200)
                .usingJobData("runs", 0)
                .requestRecovery()
                .storeDurably().build();
    }

    @Bean
    Trigger overdueLoanRemindersTrigger(JobDetail overdueLoanRemindersJob,
                                        @Value("${bookshelf.overdue.cron:0 0 2 ? * MON-FRI}") String cron) {
        return newTrigger().forJob(overdueLoanRemindersJob).withIdentity("overdueLoanRemindersTrigger", "bookshelf")
                .usingJobData("batchSize", 5)                  // the trigger wins over the 200 on the JobDetail
                .withSchedule(cronSchedule(cron).withMisfireHandlingInstructionFireAndProceed()).build();
    }
}

The JobDetail read back from the store reported durable=true, requestsRecovery=true, isPersistJobDataAfterExecution=true and isConcurrentExecutionDisallowed=true; the job class is the OverdueLoanRemindersJob of the previous sections.

Pitfalls

Table 9. Job and job-data pitfalls
Pitfall Symptom Fix

State kept in an instance field

counter or cache starts at 0 on every run

store it in the JobDetail map with @PersistJobDataAfterExecution, or in your own table

Writing to getMergedJobDataMap()

the change is gone on the next run

write to context.getJobDetail().getJobDataMap()

Writing to the JobDetail map without the annotation

change lost at the end of the run

add @PersistJobDataAfterExecution (and @DisallowConcurrentExecution)

Trigger data and job data share a key

the value you set on the JobDetail never arrives

the trigger wins on equal keys; rename or drop one

Non-serializable value with the JDBC store

Unable to serialize JobDataMap …​ not serializable

store identifiers and Strings, rebuild the object in the job

useProperties=true with non-String values

JobDataMap values must be Strings at startup

store Strings only, or leave useProperties off

Setter injection of a service

field is null in the job

constructor injection (or @Autowired)

@DisallowConcurrentExecution assumed to be per class

two `JobDetail`s of one class overlap

one JobDetail, or a lock around the shared resource

Non-durable job with a removable trigger

job disappears after the last trigger

storeDurably()

requestRecovery on the in-memory store

no recovery after a restart

use the JDBC store; recovery needs persisted fired-trigger rows

Refire without a changing condition

job loops forever in one worker thread

change state between attempts, or rethrow with false

Several JobInterruptMonitorPlugin jobs at once

a long job is never interrupted

one monitored job at a time, or a deadline inside the job

Reminder or batch keyed on "now"

a refire, recovery or late misfire runs the occasion twice

key on getScheduledFireTime() and claim with a unique constraint

Side effect inside the transaction

rollback leaves the mail sent

outbox row in the transaction, dispatch afterwards, or an idempotency key

What changed

  • Quartz 2.4.0 removed NativeJob from quartz-jobs for security reasons and moved third-party libraries to provided scope in the build.

  • Quartz 2.5.0 moved to the Jakarta namespace (jakarta.mail in SendMailJob) and needs Java 11; the 2.4.x line keeps javax and Java 8. The job API on this page (Job, JobDataMap, JobExecutionException, InterruptableJob) is unchanged across both lines.

  • Quartz 2.5.2 is the release verified here; the JobInterruptMonitorPlugin single-future behaviour comes from its source, and the project has a leak fix for the plugin on its main branch that is not part of 2.5.2.

  • Spring Framework 7 / Spring Boot 4.1 put the Quartz auto-configuration in org.springframework.boot.quartz.autoconfigure (Boot 3.5: org.springframework.boot.autoconfigure.quartz) and bring Spring Batch 6, where JobOperator absorbed JobLauncher and RepeatStatus moved to org.springframework.batch.infrastructure.repeat.