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.

sequenceDiagram autonumber participant C as Librarian client participant L as LoanService transaction participant S as Scheduler injected bean participant DB as PostgreSQL loan and QRTZ tables participant J as MemberReminderJob C->>L: lend book 7 to member 42, due in 10 days L->>DB: INSERT INTO loan L->>S: scheduleJob loan-1001 in group reminders, startAt due minus 2 days S->>DB: INSERT job, trigger, simple trigger rows, same connection L->>DB: COMMIT, loan row and reminder become visible together alt book returned before the reminder time C->>L: give back loan 1001 L->>DB: DELETE FROM loan L->>S: deleteJob loan-1001 S->>DB: DELETE trigger and job rows L->>DB: COMMIT Note over S,J: nothing is left to fire else reminder time arrives first S->>J: fire loan-1001 with memberId 42 and loanId 1001 J->>J: load the loan, send the mail only if it is still open end
Figure 1. A loan creates a reminder, returning the book deletes it

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.

application.yml of the test project
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

Table 1. Scheduling methods of the Scheduler interface in 2.5.2 (signatures read from the source, results measured)
Method and return type What it does Measured behaviour

Date scheduleJob(JobDetail, Trigger)

Stores the job and its trigger together; the trigger is pointed at the job. Returns the first fire time.

Duplicate keys throw ObjectAlreadyExistsException (a SchedulerException): Unable to store Job : 'reminders.loan-1001', because one already exists with this identification.

Date scheduleJob(Trigger)

Adds a trigger to a job that already exists (the trigger must name it with forJob).

Used after addJob; returned the first fire time.

void scheduleJob(JobDetail, Set<? extends Trigger>, boolean replace)

Stores a job with several triggers. With replace true an existing job and its triggers are replaced. Returns nothing.

Create-or-replace: after the second call with replace=true the job had exactly one trigger.

void scheduleJobs(Map<JobDetail, Set<? extends Trigger>>, boolean replace)

The bulk form of the above in one store operation.

Two reminders stored by one call.

boolean unscheduleJob(TriggerKey)

Removes one trigger. A non-durable job whose last trigger is removed is deleted too. Returns whether the trigger existed.

true for loan-1002 and the job was gone; for a durable job the job stayed; false for an unknown key.

boolean unscheduleJobs(List<TriggerKey>)

Bulk removal; the Javadoc warns it may hold the store locks for one long stretch instead of many short ones.

Returned false when one of two keys did not exist.

Date rescheduleJob(TriggerKey, Trigger)

Replaces a trigger by another for the same job; the new trigger may have a different key. Returns the new first fire time, or null when the old key was not found (and then the new trigger is not stored).

Returned the new time; null for an unknown key.

void addJob(JobDetail, boolean replace)

Stores a job with no trigger. The job must be durable.

A non-durable job throws SchedulerException: Jobs added with no trigger must be durable.

void addJob(JobDetail, boolean replace, boolean storeNonDurableWhileAwaitingScheduling)

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 checkExists true.

boolean deleteJob(JobKey)

Removes the job and all of its triggers. Returns whether the job existed.

true, then false when called again: an unknown key is not an error.

boolean deleteJobs(List<JobKey>)

Bulk deletion; returns true only if every job was found.

false for one existing and one unknown key; the existing one was deleted.

void triggerJob(JobKey) and void triggerJob(JobKey, JobDataMap)

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 memberId 99 the run saw 99 and the stored loanId.

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:

Creating, replacing, moving and deleting a reminder with the Scheduler API
// 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.

Table 2. Pause and resume methods (all return void)
Method What it pauses or resumes Measured behaviour

pauseJob(JobKey), resumeJob(JobKey)

All current triggers of one job.

getTriggerState went NORMAL → PAUSED → NORMAL.

pauseTrigger(TriggerKey), resumeTrigger(TriggerKey)

One trigger.

Same transitions.

pauseJobs(GroupMatcher<JobKey>), resumeJobs(GroupMatcher<JobKey>)

The jobs of every group the matcher selects.

Not run separately; same mechanism as the trigger variants.

pauseTriggers(GroupMatcher<TriggerKey>), resumeTriggers(GroupMatcher<TriggerKey>)

Every trigger group the matcher selects, and remembers the group.

getPausedTriggerGroups() returned [reminders]; a trigger added to the group afterwards was created PAUSED; after resumeTriggers the set was empty and the trigger NORMAL.

pauseAll(), resumeAll()

Every trigger, and remembers that everything is paused, until resumeAll.

getPausedTriggerGroups() returned [reminders, $_ALL_GROUPS_PAUSED$_]; a trigger added after pauseAll was PAUSED; resumeAll emptied the set and the trigger was NORMAL.

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.

Pausing the reminders for maintenance and resuming them
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

Table 3. Query methods
Method and return type What it returns Measured behaviour

List<String> getJobGroupNames(), List<String> getTriggerGroupNames()

Known groups.

[reminders] for both.

Set<JobKey> getJobKeys(GroupMatcher<JobKey>), Set<TriggerKey> getTriggerKeys(GroupMatcher<TriggerKey>)

Keys in the matching groups.

[loan-1001, loan-1002, loan-1003] after the bulk call.

List<? extends Trigger> getTriggersOfJob(JobKey)

The job’s triggers.

One trigger per scheduled reminder.

JobDetail getJobDetail(JobKey), List<JobDetail> getJobDetails(GroupMatcher<JobKey>), Trigger getTrigger(TriggerKey)

Snapshots of what is stored. Changing them changes nothing; re-store them with addJob or rescheduleJob.

null for an unknown key, not an exception.

TriggerState getTriggerState(TriggerKey)

NONE, NORMAL, PAUSED, COMPLETE, ERROR or BLOCKED.

NONE for an unknown key.

boolean checkExists(JobKey), boolean checkExists(TriggerKey)

Existence test without loading anything.

true and false as expected.

Set<String> getPausedTriggerGroups()

Groups currently paused, including the pseudo group of pauseAll.

See above.

void resetTriggerFromErrorState(TriggerKey)

Moves an ERROR trigger back to NORMAL (or PAUSED if its group is paused); a no-op for any other state.

Not run.

List<JobExecutionContext> getCurrentlyExecutingJobs()

The jobs running in this scheduler instance right now.

One entry with key ops.slow and a fireInstanceId.

SchedulerMetaData getMetaData()

Name, store class, thread pool size, counters.

BookshelfScheduler, LocalDataSourceJobStore, 10 threads.

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.

Listing and checking
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

Table 4. Lifecycle methods
Method and return type Meaning Measured behaviour

void standby(), void start(), boolean isInStandbyMode(), boolean isStarted()

standby halts the firing of triggers on this scheduler instance; nothing is deleted and the scheduler can be started again. start leaves standby.

In standby isInStandbyMode() was true and isStarted() still true. A trigger that fell due was not fired during 3 s of standby (its state stayed NORMAL), scheduleJob still worked, and after start() the reminder fired.

boolean interrupt(JobKey), boolean interrupt(String fireInstanceId)

Calls interrupt() on the running instances of an InterruptableJob, in this scheduler only. Throws UnableToInterruptJobException if the running job does not implement the interface.

true for a running InterruptableJob, whose interrupt() set its flag and ended the run; false when the job was not running.

void shutdown(), void shutdown(boolean waitForJobsToComplete), boolean isShutdown()

Ends the scheduler; unlike standby it cannot be restarted.

Not run here; Spring’s wait-for-jobs-to-complete-on-shutdown property drives this, see Spring Boot Configuration.

void clear()

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.

Standby on one node, and interrupting a running job
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.

Table 5. The cookbook recipes, as run
Recipe Code Result

StoreJob

JobDetail built with storeDurably(), then sched.addJob(job, false)

Stored, zero triggers. (The printed snippet builds job1 and passes job, a typo.)

ScheduleStoredJob

newTrigger()…​forJob(jobKey("job1", "group1")).build() then sched.scheduleJob(trigger)

Trigger NORMAL, first fire time returned.

ListJobs, ListTriggers

Loops over getJobGroupNames() and getJobKeys(groupEquals(group)), and the trigger equivalents

group1.job1 and group1.trigger1.

JobTriggers

sched.getTriggersOfJob(jobKey("jobName", "jobGroup"))

[group1.trigger1].

UpdateJob

newJob(MyJobClass.class).withIdentity(…​).build() then scheduler.addJob(job1, true)

Fails as printed: SchedulerException: Jobs added with no trigger must be durable. The replacement must call storeDurably(). With it the job was replaced, its existing trigger was kept, and the job data was replaced, not merged: {memberId=99} replaced {memberId=42, loanId=1}.

UpdateTrigger, replacing

A new trigger with another key and no forJob, then sched.rescheduleJob(triggerKey("oldTrigger", "group1"), trigger)

Works: Quartz copies the job key from the old trigger (QuartzScheduler.rescheduleJob calls setJobKey(oldTrigger.getJobKey())), the old key was gone, the new one existed and pointed at group1.job1. forJob is therefore optional here.

UpdateTrigger, updating

oldTrigger.getTriggerBuilder() then tb.withSchedule(simpleSchedule()…​)

The printed snippet lacks a closing parenthesis and does not compile with the declared generic type: getTriggerBuilder() returns TriggerBuilder<? extends Trigger> and withSchedule rejects it. With a raw TriggerBuilder it compiles (unchecked) and gave a SimpleTriggerImpl with 10 repeats every 10 s, the same job and the old start time. The portable form is to rebuild the trigger with TriggerBuilder.newTrigger() and the old key, as in the REST example below.

UnscheduleJob

scheduler.unscheduleJob(triggerKey(…​)) and scheduler.deleteJob(jobKey(…​))

Unscheduling kept the durable job; deleteJob removed the job and its trigger.

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:

Updating a job and a trigger, corrected
// 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.

ReminderService: create, move, cancel, list
@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:

MemberReminderJob with a constructor-injected collaborator
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:

ReminderController: POST creates, PUT moves, DELETE cancels, GET lists
@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):

Table 6. Requests and responses (anonymous, MEMBER, LIBRARIAN)
Request Status Notes

anonymous GET /api/reminders, anonymous POST

401

WWW-Authenticate: Basic realm="Realm", charset="UTF-8"

MEMBER (user 7) POST, DELETE, GET without a member id

403

Role check by @PreAuthorize.

LIBRARIAN POST loans 1001 and 1002 for member 42 and 1003 for member 7

201 x 3

Location: /api/reminders/1001 and so on.

LIBRARIAN GET /api/reminders

200

[{"loanId":1001,"memberId":42,"remindAt":"2026-10-06T10:53:57Z","state":"NORMAL"}, …​], sorted by time, three entries.

LIBRARIAN GET /api/reminders?memberId=42

200

Two entries (loans 1001 and 1002).

LIBRARIAN PUT /api/reminders/1001 with a new remindAt

204

rescheduleJob returned a date.

LIBRARIAN PUT /api/reminders/9999

404

rescheduleJob returned null.

LIBRARIAN DELETE /api/reminders/1001, then again

204, then 404

deleteJob returned true, then false; the next GET listed loans 1002 and 1003.

MEMBER 42 GET ?memberId=42

200

Only loan 1002 (1001 had been cancelled).

MEMBER 42 GET ?memberId=7, and GET without a parameter

403

The expression compares the parameter with authentication.name.

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.

A stateless chain with method security: SecurityFilterChain and @EnableMethodSecurity
@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.)

LoanService: the business row and the reminder in one transaction
@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:

Table 7. Rollback of the loan transaction, with and without a shared DataSource
Step Same DataSource (default) Separate @QuartzDataSource

lend commits

loans 1, checkExists(loan-1001) true

loans 1, reminder exists true

lend throws after scheduleJob, transaction rolls back

loans still 1, checkExists(loan-1002) false: the reminder was rolled back with the loan

loans still 1, checkExists(loan-1002) true: an orphan reminder for a loan that does not exist

giveBack commits

loans 0, reminder deleted

loans 0, reminder deleted

Where the 11 QRTZ_* tables are

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_COMMIT listener, so a rollback never creates a reminder. The price is a window between the commit and the scheduleJob in 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 reminders group 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.

Scheduling only if the loan transaction commits
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.

The same reminder with a TaskScheduler and with Quartz, across a restart
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:

Table 8. Restart test: what survived
Time Event

0.6 s

Both reminders scheduled for about five seconds after the start; the future reported isDone=false and a delay of 4415 ms.

1.6 s

Application stopped. TaskScheduler shutdown cancelled the pending task: future.isDone=true, isCancelled=true.

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 NORMAL) and had no way to find the other one.

9.9 s

sent = [Sent[memberId=42, loanId=1001, by=quartz]]: only the Quartz reminder fired, by the restarted instance, after its time had passed during the downtime. deleteJob of loan 1002 by key, from the new process, returned true.

Source: Quartz documentation: Best Practices. Output of a test run, quoted as measured.

Table 9. Dynamic scheduling with a TaskScheduler and with Quartz
Aspect TaskScheduler.schedule(…​) Quartz Scheduler

Where the schedule lives

Heap of the JVM, as a ScheduledFuture

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 ScheduledFuture you kept in a map of your own (also lost on restart, and not shared between instances)

deleteJob, getJobKeys, rescheduleJob by key, from any node

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

JobDataMap, stored with the trigger and visible to operators

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

Table 10. Runtime scheduling pitfalls and what to do instead
Pitfall What happens and the fix

scheduleJob for a key that exists

ObjectAlreadyExistsException. Use scheduleJob(job, Set.of(trigger), true), or test checkExists first (and accept the race on a cluster).

addJob with a non-durable job

SchedulerException: Jobs added with no trigger must be durable. The UpdateJob cookbook snippet fails for this reason. Add storeDurably() or use the three-argument addJob.

rescheduleJob for an unknown trigger key

Returns null and stores nothing: the new trigger is not created. Check the result, as the controller does.

deleteJob or unscheduleJob for an unknown key

Return false, no exception. Map that to 404 or ignore it deliberately; deleteJobs is true only if every job existed.

addJob(replace=true) as a "partial update"

The whole JobDataMap is replaced, not merged ({memberId=42, loanId=1} became {memberId=99}). Read the stored map first, change it, then store it.

pauseAll() as a maintenance switch, then new triggers

Triggers created after it are PAUSED until resumeAll(); forgetting resumeAll leaves the pseudo group $_ALL_GROUPS_PAUSED$_ in the store for the whole cluster. Prefer standby() for one node.

Group matchers and groups that do not exist yet

pauseTriggers with a prefix matcher does not pause groups created later; an exact-group matcher does remember a group that has no members 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.

getCurrentlyExecutingJobs and interrupt on a cluster

Local to the node you call; they say nothing about the others. Use your own bookkeeping or the read-only QRTZ_FIRED_TRIGGERS table.

Modifying a JobDetail or Trigger you got from the scheduler

They are snapshots; nothing changes until you store them again (addJob, rescheduleJob).

getJobDetail(key) assumed non-null

null when a reminder fired and was deleted between listing and lookup. Skip it, as ReminderService.list does.

@PreAuthorize without @EnableMethodSecurity

Silently ignored: measured, a MEMBER could create and delete reminders and read another member’s. Enable method security and test the 403.

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 QRTZ_LOCKS lock is held until commit; measured 1.5 s of waiting for other schedulers, and the scheduler thread’s firing (triggersFired) waits behind it too. Schedule last, commit quickly.

scheduleJob and a transaction that rolls back, with a separate @QuartzDataSource

The reminder stays (measured): an orphan. Schedule AFTER_COMMIT, make the job re-check the loan, and reconcile periodically.

@Transactional without rollbackFor = Exception.class around scheduleJob

SchedulerException is checked, so the business row commits while the reminder failed. Declare rollbackFor.

A reminder time in the past

Fires at once (smart misfire policy for a one-shot trigger, see Triggers & Schedules); check remindAt before scheduling if that is wrong.

TaskScheduler.schedule for work that must outlive the process

The ScheduledFuture is lost on restart (measured). Use the Quartz Scheduler.

Source: Quartz 2.5.x Javadoc: Scheduler and the measurements of this page.

What changed

Table 11. Facts about dynamic scheduling that changed, or that the documentation gets wrong
Topic Before Now (Quartz 2.5.2, Boot 4.1.1, Spring Security 7.1.1)

Cookbook UpdateJob

Shown as newJob(…​).build() then addJob(job1, true)

Throws Jobs added with no trigger must be durable. for a non-durable job; add storeDurably()

Cookbook UpdateTrigger, getTriggerBuilder() form

Printed with a raw builder and an unbalanced parenthesis

Does not compile with the declared TriggerBuilder<? extends Trigger> type; rebuild the trigger with TriggerBuilder.newTrigger() and the old key

Cookbook StoreJob

sched.addJob(job, false) after building job1

Typo; the call must pass the built JobDetail

Boot package of @QuartzDataSource

org.springframework.boot.autoconfigure.quartz

org.springframework.boot.quartz.autoconfigure

Boot web starters

spring-boot-starter-web, spring-boot-starter-test only

spring-boot-starter-webmvc, spring-boot-starter-webmvc-test and spring-boot-starter-security-test; @AutoConfigureMockMvc is in org.springframework.boot.webmvc.test.autoconfigure

Spring Security configuration style

WebSecurityConfigurerAdapter (removed in Security 6)

A SecurityFilterChain bean and @EnableMethodSecurity; the lambda DSL works unchanged on Security 6.5 and 7.1

TaskScheduler one-shot overloads

schedule(Runnable, Date)

schedule(Runnable, Instant) is the abstract method in Spring Framework 7.0.9; the Date overload is a default method

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 quartz endpoint 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, including overwrite-existing-jobs and 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.