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:
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:
| Firing | Instance | counter |
getRefireCount() |
|---|---|---|---|
1, first call |
|
1 |
0 |
1, refire |
|
2 |
1 |
2, first call |
|
1 |
0 |
2, refire |
|
2 |
1 |
3, first call |
|
1 |
0 |
3, refire |
|
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:
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
}
}
Source: Spring Framework: QuartzJobBean
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 withJobBuilder.usingJobData(…)— data that belongs to the job; -
the map on the
Trigger, set withTriggerBuilder.usingJobData(…)— data that belongs to one schedule of the job. An ad-hoc run throughScheduler.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:
@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
}
}
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);
Source: Quartz Javadoc: JobBuilder
Run with three firings, the job saw the same merged values every time, except for the counter that comes back from the stored map:
| Moment | Content |
|---|---|
merged map, run 1 |
|
merged map, run 2 |
|
merged map, run 3 |
|
|
|
The figure shows the same flow: two maps merge into a copy, and only a write to the JobDetail map can come back.
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
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@PersistJobDataAfterExecutionand the map was changed (it is dirty). Writes togetMergedJobDataMap()are never stored. -
Without the annotation, changes to the
JobDetailmap 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. -
@PersistJobDataAfterExecutionapplies to the class, and the stored map is perJobDetail. Quartz’s own Javadoc says such a job should seriously consider@DisallowConcurrentExecutionas 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; aSerializablerecord round-trips. -
With
org.quartz.jobStore.useProperties=truethe map is stored as text instead, and every value must be a String; anything else is rejected. TheputAsStringandgetIntFromStringstyle helpers onJobDataMapexist for this mode.
| Stored value | Result |
|---|---|
|
stored, read back as |
|
|
|
|
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.
@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.
// 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 groupRECOVERING_JOBS, namedrecover_<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, andJobExecutionContext.isRecovering()returnstrue;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).
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:
| What the job does | Effect | Measured |
|---|---|---|
throws |
logged; the trigger fires again at its next time |
trigger stayed |
throws an unchecked exception ( |
logged at ERROR, wrapped by Quartz in a |
trigger stayed |
|
|
see the example below |
|
the trigger that fired this run is set to |
trigger 1 |
|
every trigger of the job is set to |
both triggers |
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:
@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
JobExecutionExceptionwith the cause andfalse, so listeners and metrics see a failure while the schedule continues. A swallowed exception looks like a success to every listener. -
Use
setUnscheduleFiringTriggerorsetUnscheduleAllTriggersfor 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.
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; }
}
Source: Quartz Javadoc: InterruptableJob
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:
@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();
}
}
Source: Quartz documentation: FAQ
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().
| Call | Result |
|---|---|
|
|
|
|
|
|
|
|
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.
spring:
quartz:
properties:
org.quartz.plugin.jobInterrupt.class: org.quartz.plugins.interrupt.JobInterruptMonitorPlugin
org.quartz.plugin.jobInterrupt.defaultMaxRunTime: "300000"
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:
| Setup | Outcome |
|---|---|
|
|
|
|
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:
| Source of re-execution | Why |
|---|---|
refire ( |
the job asked for it |
recovery ( |
the node died mid-run and the job runs again from the start |
misfire policy |
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 |
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 withFIRE_ONCE_NOWorFIRE_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 fromgetPreviousFireTime(), 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:
@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.
@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);
}
}
}
Source: Spring Framework: QuartzJobBean
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’sLocalDataSourceJobStoreuses 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 theJobDetailandTriggerbeans at startup.
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.
@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);
}
}
}
Source: Spring Batch: Running a Job
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:
<dependency>
<groupId>org.quartz-scheduler</groupId>
<artifactId>quartz-jobs</artifactId>
</dependency>
Source: Quartz source: quartz-jobs
The jobs in 2.5.2, read from the module’s sources:
| Class | What it does |
|---|---|
|
does nothing; useful as a placeholder or in tests |
|
scans a directory and calls a |
|
checks one file’s modification time and calls a |
|
sends an e-mail described entirely by job data ( |
|
invokes a JMX operation named in the job data |
|
send a JMS message |
|
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:
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.
@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();
}
}
Source: Spring Boot: Quartz Scheduler
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
| Pitfall | Symptom | Fix |
|---|---|---|
State kept in an instance field |
counter or cache starts at 0 on every run |
store it in the |
Writing to |
the change is gone on the next run |
write to |
Writing to the |
change lost at the end of the run |
add |
Trigger data and job data share a key |
the value you set on the |
the trigger wins on equal keys; rename or drop one |
Non-serializable value with the JDBC store |
|
store identifiers and Strings, rebuild the object in the job |
|
|
store Strings only, or leave |
Setter injection of a service |
field is |
constructor injection (or |
|
two `JobDetail`s of one class overlap |
one |
Non-durable job with a removable trigger |
job disappears after the last trigger |
|
|
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 |
Several |
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 |
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
NativeJobfromquartz-jobsfor security reasons and moved third-party libraries toprovidedscope in the build. -
Quartz 2.5.0 moved to the Jakarta namespace (
jakarta.mailinSendMailJob) and needs Java 11; the 2.4.x line keepsjavaxand 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
JobInterruptMonitorPluginsingle-futurebehaviour 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, whereJobOperatorabsorbedJobLauncherandRepeatStatusmoved toorg.springframework.batch.infrastructure.repeat.