Dynamic Scheduling & Management
|
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. |
Most schedules are known when the application starts: the nightly overdue-loan reminders, the weekly report, the monthly cleanup are declared as beans, see
Getting Started with Spring Boot. Some are not. The Bookshelf promises each member a "return your book"
reminder two days before a loan is due, which means one new schedule per loan, created while the application runs and cancelled when the book comes back. That is
dynamic scheduling: creating, changing, pausing and removing jobs and triggers through the Scheduler that Spring Boot injects, and it is the main reason to choose
Quartz over @Scheduled. This page tours the whole runtime API with the value each call returns, rewrites the cookbook’s list, update and unschedule recipes, wraps
the reminders in a secured REST API, shows how to keep the loan and its reminder consistent in one transaction, and ends by showing what the same reminder looks like
with a plain TaskScheduler.
The trigger and job definitions themselves are on Triggers & Schedules and Jobs & Job Data; the store that makes dynamic schedules survive a restart is on Job Stores & Persistence; what a resume does to a trigger that missed its time is on Misfires.
It was written against Quartz 2.5.2, Spring Boot 4.1.1, Spring Framework 7.0.9, Spring Security 7.1.1 and Java 21 and verified on 5 October 2026: every behaviour
below was run in a test project (ten tests, PostgreSQL 17.11 in Testcontainers 2.0.5, the JDBC store with PostgreSQLDelegate), and the results are quoted, not assumed.
Spring Boot 3.5.16 manages the same Quartz 2.5.2, so the whole Scheduler API behaves identically there; what differs is spelling around it: the starter is
spring-boot-starter-web instead of spring-boot-starter-webmvc, @QuartzDataSource lives in org.springframework.boot.autoconfigure.quartz, Spring Framework is 6.2.19
and Spring Security is 6.5.11 (the SecurityFilterChain code below uses the same lambda DSL there). Boot 3.5 was not run.
The Bookshelf reminder lifecycle
The convention used here and on the other pages: the reminder of loan 1001 has job key and trigger key loan-1001 in the group reminders, the job class is
MemberReminderJob, and the job data holds memberId and loanId. Using the loan identifier in the key makes every operation idempotent and lets any node, or an
operator, find a reminder without a lookup table.
The figure is what the rest of the page implements: the first half in the services, the transaction boundary in
Transactional consistency, and the alt branch is why the job must re-check the loan (a reminder can fire for a loan that was returned a
moment earlier).
The injected Scheduler
Spring Boot’s spring-boot-starter-quartz registers one Scheduler bean (bean name quartzScheduler, scheduler name from spring.quartz.scheduler-name, here
BookshelfScheduler). With the JDBC store its job store is LocalDataSourceJobStore and getMetaData().isJobStoreSupportsPersistence() is true, which the first
line of the run confirmed. Inject it like any bean; every method below throws SchedulerException (a checked exception) unless the table says otherwise, and every
call goes through the store, so on a clustered JDBC store a change made on one node is seen by all of them, see
Clustering.
The examples on this page ran with this configuration: the JDBC store on PostgreSQL, the schema created beforehand by a migration (the tests let Boot create it), and the credentials from the environment.
spring:
datasource:
url: ${DB_URL}
username: ${DB_USER}
password: ${DB_PASSWORD}
quartz:
job-store-type: jdbc
scheduler-name: BookshelfScheduler
jdbc:
initialize-schema: never
properties:
org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
Source: Spring Boot: Quartz Scheduler. Used by every test of this page on Boot 4.1.1; the tests override the three datasource properties with the Testcontainers values and set initialize-schema=always (which drops and recreates the tables, test use only, see Job Stores & Persistence).
Creating, replacing and removing schedules
| Method and return type | What it does | Measured behaviour |
|---|---|---|
|
Stores the job and its trigger together; the trigger is pointed at the job. Returns the first fire time. |
Duplicate keys throw |
|
Adds a trigger to a job that already exists (the trigger must name it with |
Used after |
|
Stores a job with several triggers. With |
Create-or-replace: after the second call with |
|
The bulk form of the above in one store operation. |
Two reminders stored by one call. |
|
Removes one trigger. A non-durable job whose last trigger is removed is deleted too. Returns whether the trigger existed. |
|
|
Bulk removal; the Javadoc warns it may hold the store locks for one long stretch instead of many short ones. |
Returned |
|
Replaces a trigger by another for the same job; the new trigger may have a different key. Returns the new first fire time, or |
Returned the new time; |
|
Stores a job with no trigger. The job must be durable. |
A non-durable job throws |
|
With the third flag true a non-durable job may be stored without a trigger; it becomes non-durable again once scheduled. |
Stored with zero triggers and |
|
Removes the job and all of its triggers. Returns whether the job existed. |
|
|
Bulk deletion; returns |
|
|
Fires the job now through a one-shot trigger, whatever its own triggers say. The map is merged over the job data for this run. |
The mail went out at once; the job and its pending trigger were untouched; with a map holding |
Source: Quartz 2.5.x Javadoc: Scheduler and the 2.5.2 source of Scheduler and QuartzScheduler. Measured in a test run on the JDBC store.
The calls that create the Bookshelf reminders look like this:
// scheduleJob(job, trigger): fails if loan-1001 already exists
Date first = scheduler.scheduleJob(job, trigger);
// create or replace in one call (void): the idempotent choice for an API
scheduler.scheduleJob(job, Set.of(trigger), true);
// a durable job first, a trigger later
scheduler.addJob(durableJob, false);
scheduler.scheduleJob(TriggerBuilder.newTrigger()
.withIdentity("loan-3001", "reminders")
.forJob(durableJob)
.startAt(Date.from(Instant.now().plusSeconds(3600)))
.build());
// move a reminder: null means there was nothing to move
Date moved = scheduler.rescheduleJob(TriggerKey.triggerKey("loan-1001", "reminders"), newTrigger);
// cancel: false means there was nothing to cancel
boolean cancelled = scheduler.deleteJob(JobKey.jobKey("loan-1001", "reminders"));
// run it now, with extra data for this run only
JobDataMap extra = new JobDataMap();
extra.put("memberId", 99L);
scheduler.triggerJob(JobKey.jobKey("loan-1001", "reminders"), extra);
Source: Quartz Cookbook — Scheduling a job and Scheduler Javadoc. Run on Quartz 2.5.2 with Spring Boot 4.1.1 and PostgreSQL 17.11: scheduleJob returned the first fire time, the second scheduleJob(…, true) left one trigger, rescheduleJob returned the new first fire time (30 minutes ahead), deleteJob returned true and then false, and triggerJob produced Sent[memberId=99, loanId=1001].
Pausing and resuming
Pausing keeps the schedule and stops the firing: the trigger goes to state PAUSED in the store and nothing runs until it is resumed. Quartz also remembers
paused groups, which matters for triggers created afterwards.
| Method | What it pauses or resumes | Measured behaviour |
|---|---|---|
|
All current triggers of one job. |
|
|
One trigger. |
Same transitions. |
|
The jobs of every group the matcher selects. |
Not run separately; same mechanism as the trigger variants. |
|
Every trigger group the matcher selects, and remembers the group. |
|
|
Every trigger, and remembers that everything is paused, until |
|
Source: Quartz 2.5.x Javadoc: Scheduler. Measured on the JDBC store; the group names come from getPausedTriggerGroups().
Three details from the Javadoc matter in practice. The group matchers are applied to the groups that exist now: pausing the groups that start with a remembers aaa
but not a later axx, while pausing the exact group axx is remembered even before it has any member. Resuming applies each trigger’s misfire instruction, so a
trigger that was paused across its fire time does what its policy says (see Misfires). And pauseAll is not standby: the
first marks triggers paused in the store for the whole cluster, the second only stops this one scheduler thread from firing.
scheduler.pauseTriggers(GroupMatcher.triggerGroupEquals("reminders")); // new reminders are paused too
// ... migrate, deploy, investigate ...
scheduler.resumeTriggers(GroupMatcher.triggerGroupEquals("reminders")); // misfire instructions apply now
scheduler.pauseJob(JobKey.jobKey("loan-1001", "reminders")); // one loan
scheduler.resumeJob(JobKey.jobKey("loan-1001", "reminders"));
Source: Quartz Tutorial — Lesson 2: The Quartz API, Jobs and Triggers. Run on Quartz 2.5.2: the states and the remembered group behaved as in the table above.
Looking things up
| Method and return type | What it returns | Measured behaviour |
|---|---|---|
|
Known groups. |
|
|
Keys in the matching groups. |
|
|
The job’s triggers. |
One trigger per scheduled reminder. |
|
Snapshots of what is stored. Changing them changes nothing; re-store them with |
|
|
|
|
|
Existence test without loading anything. |
|
|
Groups currently paused, including the pseudo group of |
See above. |
|
Moves an |
Not run. |
|
The jobs running in this scheduler instance right now. |
One entry with key |
|
Name, store class, thread pool size, counters. |
|
Source: Quartz 2.5.x Javadoc: Scheduler. Measured in a test run on the JDBC store.
getCurrentlyExecutingJobs and interrupt are not cluster aware: both look only at the scheduler instance they are called on, so on a cluster the answer to "is the
weekly report running?" has to come from the database (the QRTZ_FIRED_TRIGGERS table, read-only) or from your own bookkeeping. The result is also a snapshot; the
job may have finished before you look at it.
for (String group : scheduler.getJobGroupNames()) {
for (JobKey key : scheduler.getJobKeys(GroupMatcher.jobGroupEquals(group))) {
System.out.println(key + " triggers=" + scheduler.getTriggersOfJob(key).size());
}
}
boolean exists = scheduler.checkExists(JobKey.jobKey("loan-1001", "reminders"));
TriggerState state = scheduler.getTriggerState(TriggerKey.triggerKey("loan-1001", "reminders"));
List<JobExecutionContext> running = scheduler.getCurrentlyExecutingJobs(); // this node only
Source: Quartz Cookbook — Listing Jobs in the Scheduler and GroupMatcher Javadoc. Run on Quartz 2.5.2: checkExists returned true for the stored reminder and false for an unknown key, getTriggerState returned NORMAL.
Standby, start, interrupt and the rest of the lifecycle
| Method and return type | Meaning | Measured behaviour |
|---|---|---|
|
|
In standby |
|
Calls |
|
|
Ends the scheduler; unlike |
Not run here; Spring’s |
|
Deletes all jobs, triggers and calendars. |
Not run; never call it on a shared store. |
Source: Quartz Cookbook — Scheduler standby mode and Scheduler Javadoc. Measured with a one-second-due trigger and a SlowJob extends QuartzJobBean implements InterruptableJob.
scheduler.standby(); // this node stops firing, the store is untouched
// ... for example: drain the node before a rolling deployment
scheduler.start(); // back to normal; overdue triggers go through the misfire logic
if (scheduler.interrupt(JobKey.jobKey("slow", "ops"))) { // true: an instance was interrupted
// the job's InterruptableJob.interrupt() was called; it must end its own work
}
Source: Quartz Cookbook — Scheduler standby mode. Run on Quartz 2.5.2: during standby a due trigger stayed NORMAL and did not fire; after start() it fired; interrupt returned true and the job’s flag was set. How a job should react to an interrupt is on Jobs & Job Data.
The cookbook recipes
The Quartz cookbook has a small set of "how-to" pages for exactly the operations of this page: listing (ListJobs, ListTriggers, JobTriggers), changing (UpdateJob,
UpdateTrigger), removing (UnscheduleJob) and storing for later (StoreJob, ScheduleStoredJob). Each one was run against the JDBC store, and two of them do not
work exactly as printed.
| Recipe | Code | Result |
|---|---|---|
|
|
Stored, zero triggers. (The printed snippet builds |
|
|
Trigger |
|
Loops over |
|
|
|
|
|
|
Fails as printed: |
|
A new trigger with another key and no |
Works: Quartz copies the job key from the old trigger ( |
|
|
The printed snippet lacks a closing parenthesis and does not compile with the declared generic type: |
|
|
Unscheduling kept the durable job; |
Source: StoreJob, ScheduleStoredJob, ListJobs, ListTriggers, JobTriggers, UpdateJob, UpdateTrigger and UnscheduleJob (all Quartz Cookbook). Run in a test project on Quartz 2.5.2 and PostgreSQL 17.11.
The corrected UpdateJob and the portable UpdateTrigger are worth keeping as snippets:
// UpdateJob: durable, replace = true. Triggers stay, JobDataMap is replaced as a whole.
JobDetail updated = JobBuilder.newJob(MemberReminderJob.class)
.withIdentity("loan-1001", "reminders")
.usingJobData("memberId", 42L)
.usingJobData("loanId", 1001L)
.storeDurably()
.build();
scheduler.addJob(updated, true);
// UpdateTrigger: rebuild with the same key, then replace
Trigger old = scheduler.getTrigger(TriggerKey.triggerKey("loan-1001", "reminders"));
Trigger moved = TriggerBuilder.newTrigger()
.withIdentity(old.getKey())
.forJob(old.getJobKey())
.startAt(Date.from(Instant.now().plus(Duration.ofHours(3))))
.build();
scheduler.rescheduleJob(old.getKey(), moved);
Source: Quartz Cookbook — Update an existing job and Quartz Cookbook — Updating a trigger. The same calls ran in a test project (the reschedule form is the one the REST API below uses, run through PUT /api/reminders/1001 returning 204). Note that storeDurably() here also makes a replaced reminder job survive the deletion of its last trigger.
A Bookshelf reminders REST API
The service layer is a thin wrapper that owns the key convention. It uses the create-or-replace form, so posting the same loan twice is harmless, and deleteJob for cancellation,
so cancelling twice is a 404 the second time rather than an error.
@Service
public class ReminderService {
static final String GROUP = "reminders";
public record ReminderView(long loanId, long memberId, Instant remindAt, String state) { }
private final Scheduler scheduler;
public ReminderService(Scheduler scheduler) {
this.scheduler = scheduler;
}
static JobKey jobKey(long loanId) { return JobKey.jobKey("loan-" + loanId, GROUP); }
static TriggerKey triggerKey(long loanId) { return TriggerKey.triggerKey("loan-" + loanId, GROUP); }
public Instant schedule(long memberId, long loanId, Instant remindAt) throws SchedulerException {
JobDetail job = JobBuilder.newJob(MemberReminderJob.class)
.withIdentity(jobKey(loanId))
.usingJobData("memberId", memberId)
.usingJobData("loanId", loanId)
.build();
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity(triggerKey(loanId))
.startAt(Date.from(remindAt))
.build();
scheduler.scheduleJob(job, Set.of(trigger), true); // create-or-replace
return remindAt;
}
public boolean reschedule(long loanId, Instant remindAt) throws SchedulerException {
Trigger moved = TriggerBuilder.newTrigger()
.withIdentity(triggerKey(loanId))
.forJob(jobKey(loanId))
.startAt(Date.from(remindAt))
.build();
return scheduler.rescheduleJob(triggerKey(loanId), moved) != null;
}
public boolean cancel(long loanId) throws SchedulerException {
return scheduler.deleteJob(jobKey(loanId));
}
public List<ReminderView> list(Long memberId) throws SchedulerException {
List<ReminderView> out = new ArrayList<>();
for (JobKey key : scheduler.getJobKeys(GroupMatcher.jobGroupEquals(GROUP))) {
JobDetail detail = scheduler.getJobDetail(key);
if (detail == null) continue; // fired and removed meanwhile
long member = detail.getJobDataMap().getLong("memberId");
if (memberId != null && memberId != member) continue;
for (Trigger t : scheduler.getTriggersOfJob(key)) {
if (t.getNextFireTime() == null) continue; // finished while we were looking
out.add(new ReminderView(detail.getJobDataMap().getLong("loanId"), member,
t.getNextFireTime().toInstant(),
scheduler.getTriggerState(t.getKey()).name()));
}
}
out.sort(Comparator.comparing(ReminderView::remindAt));
return out;
}
}
Source: Scheduler Javadoc and Quartz Cookbook — Listing Jobs in the Scheduler. Compiled and run on Spring Boot 4.1.1 with the JDBC store; the two continue lines exist because a one-shot reminder can fire and disappear between getJobKeys and the lookups, so getJobDetail returns null and a finished trigger has no next fire time.
The job class is the QuartzJobBean of the triggers page, with the mail gateway injected through the constructor (Spring creates jobs, so constructor injection works) and memberId and
loanId set from the job data:
public class MemberReminderJob extends QuartzJobBean {
private final ReminderSender sender;
private long memberId;
private long loanId;
public MemberReminderJob(ReminderSender sender) {
this.sender = sender;
}
public void setMemberId(long memberId) { this.memberId = memberId; }
public void setLoanId(long loanId) { this.loanId = loanId; }
@Override
protected void executeInternal(JobExecutionContext context) {
sender.send(memberId, loanId, "quartz"); // in the real service: check the loan is still open first
}
}
Source: Spring Framework: Task Execution and Scheduling. Run on Spring Boot 4.1.1: the job received its ReminderSender and the values 42 and 1001.
The controller is ordinary Spring MVC. It maps the four operations to HTTP verbs and the "nothing there" results of the Scheduler API to 404:
@RestController
@RequestMapping("/api/reminders")
class ReminderController {
record CreateReminder(long memberId, long loanId, Instant remindAt) { }
record MoveReminder(Instant remindAt) { }
private final ReminderService reminders;
ReminderController(ReminderService reminders) {
this.reminders = reminders;
}
@PostMapping
@PreAuthorize("hasRole('LIBRARIAN')")
ResponseEntity<Void> create(@RequestBody CreateReminder request) throws SchedulerException {
reminders.schedule(request.memberId(), request.loanId(), request.remindAt());
return ResponseEntity.status(HttpStatus.CREATED)
.header("Location", "/api/reminders/" + request.loanId()).build();
}
@PutMapping("/{loanId}")
@PreAuthorize("hasRole('LIBRARIAN')")
ResponseEntity<Void> move(@PathVariable long loanId, @RequestBody MoveReminder request)
throws SchedulerException {
return reminders.reschedule(loanId, request.remindAt())
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
@DeleteMapping("/{loanId}")
@PreAuthorize("hasRole('LIBRARIAN')")
ResponseEntity<Void> cancel(@PathVariable long loanId) throws SchedulerException {
return reminders.cancel(loanId)
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
@GetMapping
@PreAuthorize("hasRole('LIBRARIAN') or (#memberId != null and #memberId.toString() == authentication.name)")
List<ReminderService.ReminderView> list(@RequestParam(required = false) Long memberId)
throws SchedulerException {
return reminders.list(memberId);
}
}
Source: Spring Framework: Task Execution and Scheduling and Spring Security: Method Security. Compiled against Boot 4.1.1 (spring-boot-starter-webmvc) and exercised with MockMvc, results in the next table. The SchedulerException is declared rather than caught: it is a failure of the store (database down), which should surface as a 500, see REST APIs for a global @ExceptionHandler.
The requests were sent with MockMvc against the real Spring Security filter chain and the PostgreSQL job store (the next section shows the security setup):
| Request | Status | Notes |
|---|---|---|
anonymous |
401 |
|
|
403 |
Role check by |
|
201 x 3 |
|
|
200 |
|
|
200 |
Two entries (loans 1001 and 1002). |
|
204 |
|
|
404 |
|
|
204, then 404 |
|
|
200 |
Only loan 1002 (1001 had been cancelled). |
|
403 |
The expression compares the parameter with |
HTTP Basic with a wrong password |
401 |
Same challenge header. |
Source: Spring Boot: Testing Spring Boot Applications. Run on Boot 4.1.1 in a test project (@SpringBootTest with @AutoConfigureMockMvc, imported from org.springframework.boot.webmvc.test.autoconfigure, and @WithMockUser); the timestamps are from the run.
Securing the API
An endpoint that can create and delete schedules is an administrative interface: anyone who can post to it decides what runs and when, and a job class name must never
come from the request (the controller above fixes MemberReminderJob and takes only identifiers and an instant, so nothing the client sends is turned into a class name or a cron
expression). The security model, filters and method security are explained on Spring Security; the part that matters here is
small.
@Configuration(proxyBeanMethods = false)
@EnableMethodSecurity
class SecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").authenticated() // roles are checked by @PreAuthorize
.anyRequest().denyAll())
.httpBasic(withDefaults())
.csrf(AbstractHttpConfigurer::disable) // stateless API, no browser session
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}
Source: Spring Security: Authorize HttpServletRequests and Spring Security: Method Security. Compiled and run on Spring Security 7.1.1 (Boot 4.1.1); withDefaults is the static import of org.springframework.security.config.Customizer.withDefaults. HTTP Basic stands in for your real authentication (JWT, OAuth 2.0, see Spring Security); users come from a UserDetailsService, no password appears in the code.
Two rules are worth stating. Put the coarse rule in the filter chain (/api/** needs authentication, everything else is denied) and the fine rule next to the method (hasRole('LIBRARIAN'), or the
own-reminders expression for members), so that a new endpoint added to the controller without an annotation is still at least authenticated. And switch method security on:
the same controller was run with @EnableMethodSecurity commented out, and the @PreAuthorize annotations were silently ignored, so the MEMBER user got 201 on
POST, 204 on DELETE, and could read member 7’s reminders with 200. CSRF protection is disabled here only because the API is stateless and authenticated by a header on every request; a browser
session needs it. Do not expose the Actuator quartz endpoint without the same care, see Observability.
Transactional consistency
The loan row and its reminder are two writes, and they should succeed or fail together: a reminder for a loan that was rolled back mails a member about a loan that does not
exist, and a loan whose reminder was lost never reminds anyone. With the JDBC store they can share one transaction, because of how Spring Boot wires Quartz:
LocalDataSourceJobStore asks DataSourceUtils for its connection, and DataSourceUtils returns the connection that the current Spring transaction has bound to that
DataSource. If the DataSource Quartz uses is the same one the business code uses, every QRTZ_* insert made by scheduleJob happens on the same connection as the
INSERT INTO loan, and commits or rolls back with it. (The JobStoreCMT that LocalDataSourceJobStore extends is Quartz’s "container managed" store: it never commits itself.)
@Service
public class LoanService {
private static final ZoneId MADRID = ZoneId.of("Europe/Madrid");
private final JdbcClient jdbc;
private final ReminderService reminders;
public LoanService(JdbcClient jdbc, ReminderService reminders) {
this.jdbc = jdbc;
this.reminders = reminders;
}
@Transactional(rollbackFor = Exception.class)
public void lend(long loanId, long memberId, long bookId, Instant due) throws SchedulerException {
jdbc.sql("INSERT INTO loan (id, member_id, book_id, due_date) VALUES (?, ?, ?, ?)")
.params(loanId, memberId, bookId, java.sql.Timestamp.from(due)).update();
reminders.schedule(memberId, loanId, due.atZone(MADRID).minusDays(2).toInstant());
}
@Transactional(rollbackFor = Exception.class)
public void giveBack(long loanId) throws SchedulerException {
jdbc.sql("DELETE FROM loan WHERE id = ?").param(loanId).update();
reminders.cancel(loanId);
}
}
Source: Spring Framework: Transaction Management and SchedulerFactoryBean Javadoc; behaviour read from LocalDataSourceJobStore in Spring Framework 7.0.9. rollbackFor = Exception.class matters because SchedulerException is checked and a Spring transaction does not roll back on checked exceptions by default. The test version of lend had an extra flag that throws IllegalStateException after scheduling, to force a rollback.
The measurements, with the business tables and the Quartz tables in one PostgreSQL database (@Primary data source, nothing else) and then in two:
| Step | Same DataSource (default) |
Separate @QuartzDataSource |
|---|---|---|
|
loans 1, |
loans 1, reminder exists |
|
loans still 1, |
loans still 1, |
|
loans 0, reminder deleted |
loans 0, reminder deleted |
Where the 11 |
One database |
Only in the Quartz database (0 in the business database, 11 in the Quartz one) |
Source: Spring Boot: Quartz Scheduler. Measured in a test run on two PostgreSQL 17.11 containers; the SchedulerFactoryBean receives the Quartz data source, and with a @QuartzDataSource its connections are not the ones bound to the business transaction, so Quartz commits on its own.
Sharing the transaction has a price that the first measurement hides, which is the locking in the store. Scheduling takes the TRIGGER_ACCESS row lock of QRTZ_LOCKS
(lockOnInsert is true by default), and a lock taken inside a transaction is held until that transaction ends. Two threads were run: the first scheduled a reminder and then held its transaction open for 2000 ms; a second
thread scheduling another reminder 500 ms later waited 1519 ms, whether it did so inside its own transaction or without any (1513 ms). The rule that follows: keep the
transaction around scheduleJob short, never call a slow service (a mail gateway, a remote API) between the scheduleJob and the commit, and expect the scheduler’s firing (triggersFired) to queue
behind a long business transaction. The locks and what each call takes are on Job Stores & Persistence and
PostgreSQL; the general transaction and locking background is on
Transaction Isolation & Locking.
When the stores cannot share a transaction
With a separate @QuartzDataSource, with a Quartz store in another database engine, or with business data in MongoDB, there is no transaction around both writes (a MongoDB multi-document transaction cannot enlist a PostgreSQL connection). Pick
the failure you can live with and design for it:
-
Schedule after the commit. Publish an event inside the transaction and schedule in an
AFTER_COMMITlistener, so a rollback never creates a reminder. The price is a window between the commit and thescheduleJobin which a crash loses the reminder, so the reconciliation below is still needed. Measured on the separate-data-source setup: the commit created the reminder, the rollback created none. -
Make the job re-check. The reminder job loads the loan and returns without mailing if it is closed, which also neutralises any orphan. It should be idempotent anyway, see Jobs & Job Data.
-
Reconcile. A periodic job compares open loans with the
remindersgroup and creates the missing reminders (create-or-replace makes this harmless) and deletes the orphans. The MongoDB page does exactly that, see MongoDB and Quartz and Spring Data MongoDB for the MongoDB side.
public record LoanLent(long loanId, long memberId, Instant due) { }
// inside LoanService.lend(...): the INSERT, then
events.publishEvent(new LoanLent(loanId, memberId, due));
@Component
class ReminderScheduling {
private static final ZoneId MADRID = ZoneId.of("Europe/Madrid");
private final ReminderService reminders;
ReminderScheduling(ReminderService reminders) {
this.reminders = reminders;
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void onLoanLent(LoanLent event) throws SchedulerException {
Instant remindAt = event.due().atZone(MADRID).minusDays(2).toInstant();
reminders.schedule(event.memberId(), event.loanId(), remindAt);
}
}
Source: Spring Framework: Transaction-bound Events. Run with a separate @QuartzDataSource on Boot 4.1.1: after a committed lendThenSchedule the reminder existed; after a rolled-back one (IllegalStateException) it did not, and loans stayed at 1. The events field is an ApplicationEventPublisher.
Dynamic scheduling versus TaskScheduler
Spring’s own TaskScheduler can also schedule a one-shot task at runtime: taskScheduler.schedule(runnable, instant) returns a ScheduledFuture that you can cancel. For a task that
only matters while the application is up that is enough, and the whole mechanism is explained on
Scheduling and ShedLock. For the Bookshelf reminders it is not, because the schedule exists only as a ScheduledFuture in the heap of one JVM. The next test
scheduled the same reminder both ways for five seconds ahead, stopped the application after one second, waited two seconds, and started it again against the same database.
ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
taskScheduler.setThreadNamePrefix("reminder-");
taskScheduler.initialize();
Instant at = Instant.now().plusSeconds(5);
reminderService.schedule(42, 1001, at); // Quartz, JDBC store
ScheduledFuture<?> future = taskScheduler.schedule(
() -> sender.send(42, 1001, "taskScheduler"), at); // heap of this JVM only
// ... application stops, three seconds pass, application starts again ...
// Quartz: reminderService.list(null) still shows loan 1001, and the reminder fires
// TaskScheduler: the future is gone, nothing fires, and there is nothing to cancel
Source: TaskScheduler Javadoc and Spring Framework: Task Execution and Scheduling. Run on Spring Framework 7.0.9 and Quartz 2.5.2 in a test project.
What the run printed, as a timeline from the start of the test:
| Time | Event |
|---|---|
0.6 s |
Both reminders scheduled for about five seconds after the start; the future reported |
1.6 s |
Application stopped. |
3.8 s |
Application restarted. The restarted application listed both Quartz reminders (loan 1001 due at 10:52:10 and loan 1002 an hour later, both |
9.9 s |
|
Source: Quartz documentation: Best Practices. Output of a test run, quoted as measured.
| Aspect | TaskScheduler.schedule(…) |
Quartz Scheduler |
|---|---|---|
Where the schedule lives |
Heap of the JVM, as a |
The job store (database), keyed by job and trigger key |
After a restart or deploy |
Gone; measured: the task never ran and could not be cancelled |
Still there, fires late if its time passed (misfire policy applies, see Misfires) |
Cancelling, listing, moving |
Only with the |
|
Several instances |
Every instance has its own futures; a duplicate API call schedules twice |
One shared store; a clustered JDBC store fires each trigger on one node, see Clustering |
Data for the run |
Captured by the lambda, not inspectable |
|
Cost |
Nothing to set up |
A database schema, a store, a job class |
Source: Spring Framework: The TaskScheduler Interface. The first two rows are measured in the run above; the rest follows from the API.
The practical guidance: use TaskScheduler for in-process, loss-tolerant work (a debounce, a short timeout, a retry in a few seconds), and the Quartz Scheduler when the schedule belongs to the business data and
must survive a restart, a deployment and the loss of a node. The side-by-side comparison with @Scheduled and ShedLock for fixed schedules is on the section’s comparison page, linked from
Scheduling.
Pitfalls
| Pitfall | What happens and the fix |
|---|---|
|
|
|
|
|
Returns |
|
Return |
|
The whole |
|
Triggers created after it are |
Group matchers and groups that do not exist yet |
|
Resume after a long pause |
Misfire instructions are applied at once, so a daily trigger may fire immediately. Choose the policy with the pause in mind, see Misfires. |
|
Local to the node you call; they say nothing about the others. Use your own bookkeeping or the read-only |
Modifying a |
They are snapshots; nothing changes until you store them again ( |
|
|
|
Silently ignored: measured, a |
A job class or cron expression taken from the request |
The caller chooses what runs. Fix the job class in code and accept only identifiers, instants and validated values. |
Scheduling in the loan transaction and then calling a slow service |
The |
|
The reminder stays (measured): an orphan. Schedule |
|
|
A reminder time in the past |
Fires at once (smart misfire policy for a one-shot trigger, see Triggers & Schedules); check |
|
The |
Source: Quartz 2.5.x Javadoc: Scheduler and the measurements of this page.
What changed
| Topic | Before | Now (Quartz 2.5.2, Boot 4.1.1, Spring Security 7.1.1) |
|---|---|---|
Cookbook |
Shown as |
Throws |
Cookbook |
Printed with a raw builder and an unbalanced parenthesis |
Does not compile with the declared |
Cookbook |
|
Typo; the call must pass the built |
Boot package of |
|
|
Boot web starters |
|
|
Spring Security configuration style |
|
A |
|
|
|
Spring Boot 3.5.16 |
same Quartz 2.5.2, Spring Framework 6.2.19, Spring Security 6.5.11 |
only the spelling above differs; not run on 3.5 |
Source: Quartz releases and Spring Boot: Quartz Scheduler. The cookbook rows were verified by running the recipes (see above); the TaskScheduler row from javap on spring-context 7.0.9.
The full list of version changes and documentation discrepancies of the section is on Migration & What’s Changed.
Where to go next
-
Reading and operating schedules from outside the application: Observability (the Actuator
quartzendpoint can trigger a job now, but not pause or delete). -
Testing these calls, including the REST API with MockMvc and a cluster of two schedulers: Testing.
-
How several nodes share one set of dynamic schedules: Clustering.
-
Every
spring.quartz.*property, includingoverwrite-existing-jobsand the data source choice: Spring Boot Configuration. -
Running this in production, rolling deployments and anti-patterns: Production & Best Practices.
-
The version changes in one place: Migration & What’s Changed and the one-page Cheat Sheet.
-
The same reminders with the business data in MongoDB: MongoDB and Quartz.
-
The section’s landing page and reading path: Scheduling.