MongoDB and Quartz
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
Quartz was designed around relational databases, and MongoDB is not one. Teams that keep their domain data in MongoDB therefore ask the same question
when the first persistent, clustered job appears: can Quartz store its triggers in the database we already run? This page answers it with
measurements. In short, there is no official MongoDB job store, the community one does not work on the Quartz version that Spring Boot manages, and the
practical answer is to keep the business data in MongoDB and give Quartz a small SQL database of its own, which is option 1 below and the only option
shown with a complete, tested Bookshelf example. The other three options (the community store, a custom JobStore, and no Quartz) are documented with
what was measured, so that you can judge them without repeating the experiments.
The generic job-store concepts (what a store has to provide, the QRTZ_ tables, schema management, @QuartzDataSource) are on
Job Stores & Persistence, the reference SQL database is
PostgreSQL, and the other community store that is tested the same way is on
Redis. The MongoDB side of the story (replica sets, transactions, write concern) is covered on
MongoDB, especially Transactions and
Replication, and the Spring Data usage on Spring Data MongoDB.
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 with Testcontainers 2.0.5 on the images mongo:8 (MongoDB 8.3.11, started by MongoDBContainer as a single-node replica set) and
postgres:17 (PostgreSQL 17.11), with the MongoDB Java driver 5.8.1, Spring Data MongoDB 5.1.1, the PostgreSQL JDBC driver 42.7.13, Flyway 12.4.0,
ShedLock 7.10.1 and the community store io.fluidsonic.mirror:quartz-mongodb:2.2.0-rc2; the results are quoted, not assumed. Spring Boot 3.5.16 manages
the same Quartz 2.5.2, so the findings about Quartz and the community store are identical there; only the package of @QuartzDataSource
(org.springframework.boot.autoconfigure.quartz instead of org.springframework.boot.quartz.autoconfigure), the package of DataSourceProperties
(org.springframework.boot.autoconfigure.jdbc), the MongoDB connection property (spring.data.mongodb.uri instead of spring.mongodb.uri), Spring Framework
6.2.19 and the Testcontainers 1.x package of MongoDBContainer differ.
The status of Quartz on MongoDB
Quartz has no official MongoDB job store. Its own job stores are the in-memory RAMJobStore and the JDBC stores (JobStoreTX and JobStoreCMT), and
clustering is JDBC only since the Terracotta store was removed in 2.4.0. The only MongoDB store in circulation is a community project,
quartz-mongodb (class com.novemberain.quartz.mongodb.MongoDBJobStore). Its repository is archived
on GitHub and read-only, with the last push in November 2022. Its README still tells you to fetch it from Bintray and JCenter, and JCenter has been shut
down. The only copy on Maven Central is an unofficial mirror, io.fluidsonic.mirror:quartz-mongodb, whose latest version is 2.2.0-rc2 (a release candidate
published in 2021 and compiled against Quartz 2.3.2 and the MongoDB driver 4.0.5). That version predates Quartz 2.4 and 2.5 by years.
The consequence was measured, not guessed: on Quartz 2.5.2, the version that both Spring Boot 4.1 and Spring Boot 3.5 manage, the store loads, the scheduler reports that it has started, and then no job ever fires, because the scheduler thread dies silently. The details and the matrix are in Option 2: the community quartz-mongodb store, tested and broken on Quartz 2.5.x. This is why the four options are not equal:
| Option | What you get | Tested result on Quartz 2.5.2, Boot 4.1.1 | Recommendation |
|---|---|---|---|
1. Quartz on SQL, data in MongoDB |
Full Quartz (persistence, clustering, misfires, runtime scheduling) with the JDBC store; business data stays in MongoDB. |
Works. Eight tests on Testcontainers MongoDB and PostgreSQL, repeated three times, with Flyway and again with Liquibase (Option 1: Quartz on SQL, business data in MongoDB). |
Recommended. |
2. Community |
Everything in MongoDB, no SQL database. |
Broken: the scheduler thread dies with a |
Not recommended: it means overriding |
3. Custom |
Exactly the semantics you need, stored in MongoDB. |
Two building blocks were compiled and run (atomic trigger claim, lock documents with a TTL), the full SPI was not implemented. |
Rarely worth it. An outline is in Option 3: a custom JobStore backed by MongoDB. |
4. No Quartz: |
Cron-style jobs that run once across the instances, with a lock document in MongoDB. |
Works: two nodes, one execution per tick (Option 4: no Quartz, @Scheduled plus ShedLock with MongoDB). |
Recommended when persistence, misfire policies and runtime scheduling are not needed. |
Option 1: Quartz on SQL, business data in MongoDB
The idea is to stop asking Quartz to store its state in MongoDB at all. Quartz keeps its eleven QRTZ_ tables in a small relational database that
exists only for it (PostgreSQL here, any database from the support matrix works), and the
application keeps loans and members in MongoDB exactly as before. Nothing about MongoDB is special for Quartz: the job class loads whatever it needs from
MongoDB through a Spring Data repository when it fires, and the code that creates and cancels reminders runs next to the code that writes loans.
This costs one more database to operate, but it is a database that holds a few hundred small rows and that every operations team knows how to back up and
monitor. In return you get everything the JDBC store offers: row-lock clustering (see Clustering), misfire
handling (see Misfires), persistence across restarts and runtime scheduling with Scheduler.scheduleJob. The price,
and it is the main point of this section, is that there is no transaction spanning MongoDB and the Quartz tables. MongoDB transactions cannot include a
PostgreSQL connection, and this page does not use a distributed (XA) transaction manager, so the two systems can disagree after a crash. The design below accepts that and
makes every disagreement harmless or self-healing.
The Bookshelf reminders
The running scenario for this option is the per-member "return your book" reminder: when a loan is made, a reminder is scheduled one day before the due
date (the bookshelf.reminders.lead property); when the book is returned first, the reminder is cancelled. The conventions are the ones used on the other
pages: job key and trigger key loan-<loanId> in the group reminders, job data memberId and loanId, job class MemberReminderJob. A second job,
ReminderReconciliationJob, compares MongoDB with Quartz every ten minutes and repairs whichever side is wrong.
Dependencies
The starter for Quartz, the JDBC starter for Quartz’s own pool, Spring Data MongoDB for the domain, PostgreSQL’s driver and a schema migration tool. On Boot
4 Flyway comes through spring-boot-starter-flyway plus the database module flyway-database-postgresql; on Boot 3.5 the dependency is flyway-core (and the
database module for Flyway 10 and later).
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
</dependencies>
A dedicated data source for Quartz
Because the application has no JDBC data source of its own (its data is in MongoDB), Boot would happily use a single auto-configured one. This page declares the
Quartz data source explicitly anyway, for two reasons: the pool belongs to Quartz and should be sized for it (worker threads plus three connections, see
Production & Best Practices), and the day somebody adds a JDBC data source for something else
(an outbox, a reporting database), Quartz must not silently switch to it. A data source marked @QuartzDataSource is the one Quartz uses among the candidates, but it does not rescue a context with several data sources and no @Primary: Boot does not fail,
it silently falls back to the in-memory RAMJobStore and every job is lost at restart (this was measured, see the pitfalls table). So if an application data source is added later, mark that one @Primary.
package bookshelf;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.autoconfigure.DataSourceProperties;
import org.springframework.boot.quartz.autoconfigure.QuartzDataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;
/**
* Quartz keeps its state in a small PostgreSQL database; the business data lives in MongoDB.
* The pool is dedicated to Quartz and sized independently of everything else.
*/
@Configuration(proxyBeanMethods = false)
class QuartzDatabaseConfig {
@Bean
@ConfigurationProperties("bookshelf.quartz.datasource")
DataSourceProperties quartzDataSourceProperties() {
return new DataSourceProperties();
}
@Bean
@QuartzDataSource
@ConfigurationProperties("bookshelf.quartz.datasource.hikari")
HikariDataSource quartzDataSource(DataSourceProperties quartzDataSourceProperties) {
return quartzDataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
}
}
The scheduler itself is configured in application.yml. initialize-schema: never is deliberate: the tables are owned by a migration tool (next section), and
the default embedded creates nothing on PostgreSQL while always would drop and recreate the tables at every start. The delegate must be
PostgreSQLDelegate (the generic one fails on PostgreSQL’s bytea), and clustering is switched on because the service runs as several instances.
The password comes from the environment; there is no default for it.
spring:
application:
name: bookshelf-service
mongodb:
uri: mongodb://${MONGODB_HOST:localhost}:27017/bookshelf
flyway:
locations: classpath:db/migration
quartz:
job-store-type: jdbc
scheduler-name: BookshelfScheduler
jdbc:
initialize-schema: never # the schema is owned by Flyway
properties:
org.quartz.scheduler.instanceId: AUTO
org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
org.quartz.jobStore.isClustered: "true"
org.quartz.threadPool.threadCount: "5"
bookshelf:
quartz:
datasource:
url: jdbc:postgresql://${QUARTZ_DB_HOST:localhost}:5432/${QUARTZ_DB_NAME:quartz}
username: ${QUARTZ_DB_USER:quartz}
password: ${QUARTZ_DB_PASSWORD}
hikari:
maximum-pool-size: 8 # threadCount + 3
reminders:
lead: P1D
reconciliation:
interval: PT10M
The spring.mongodb.uri property is the Boot 4 name; on Boot 3.5 it is spring.data.mongodb.uri. A transactional replica set is needed for MongoDB multi-document
transactions, which this page does not use (see Transactions); the test container is nevertheless a replica set
because that is how Testcontainers starts mongo:8.
The Quartz schema with Flyway
Quartz ships a DDL script per database inside its jar: org/quartz/impl/jdbcjobstore/tables_postgres.sql for PostgreSQL. The script starts with eleven
DROP TABLE IF EXISTS statements (and the Spring Boot initializer runs it as is, which is why initialize-schema: always destroys data). For a migration tool
the DROP lines must go, so the Flyway version of the script is the original with exactly those lines removed. It has the standard Flyway name and location,
so Boot picks it up without any spring.flyway.* property; spring.flyway.locations is only needed when the scripts live elsewhere.
-- Thanks to Patrick Lightbody for submitting this...
--
-- In your Quartz properties file, you'll need to set
-- org.quartz.jobStore.driverDelegateClass = org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
CREATE TABLE QRTZ_JOB_DETAILS
(
SCHED_NAME VARCHAR(120) NOT NULL,
JOB_NAME VARCHAR(200) NOT NULL,
JOB_GROUP VARCHAR(200) NOT NULL,
DESCRIPTION VARCHAR(250) NULL,
JOB_CLASS_NAME VARCHAR(250) NOT NULL,
IS_DURABLE BOOL NOT NULL,
IS_NONCONCURRENT BOOL NOT NULL,
IS_UPDATE_DATA BOOL NOT NULL,
REQUESTS_RECOVERY BOOL NOT NULL,
JOB_DATA BYTEA NULL,
PRIMARY KEY (SCHED_NAME, JOB_NAME, JOB_GROUP)
);
CREATE TABLE QRTZ_TRIGGERS
...
In the test run Flyway 12.4.0 reported Database: jdbc:postgresql://localhost:<port>/test (PostgreSQL 17.11), Migrating schema "public" to version "1 - quartz
tables" and Successfully applied 1 migration, and the scheduler started afterwards. Boot orders the scheduler after the database initializers, so the tables
exist when Quartz first reads them. When the application also has another JDBC data source, point Flyway at the Quartz database with @FlywayDataSource on the bean or
with spring.flyway.url, spring.flyway.user and spring.flyway.password; that variant was not run here.
The Quartz schema with Liquibase
Liquibase users do not need to copy the SQL: the Quartz jar bundles a changelog, org/quartz/impl/jdbcjobstore/liquibase.quartz.init.xml, with a single
changeset (quartz-init, author quartz). It defines the tables with a table_prefix property (QRTZ_) and a blob_type property that resolves to BYTEA
on PostgreSQL and BLOB elsewhere. A master changelog only has to include it from the classpath. Replace the Flyway starter with
spring-boot-starter-liquibase (or disable Flyway with spring.flyway.enabled=false) and set spring.liquibase.change-log.
databaseChangeLog:
- include:
file: org/quartz/impl/jdbcjobstore/liquibase.quartz.init.xml
Source: Liquibase documentation
spring:
flyway:
enabled: false
liquibase:
change-log: classpath:db/changelog/db.changelog-master.yaml
The Liquibase variant was run too (full test class): with spring-boot-starter-liquibase instead of Flyway, all eight tests below passed against a
fresh PostgreSQL 17.11, and the log shows Running Changeset: org/quartz/impl/jdbcjobstore/liquibase.quartz.init.xml::quartz-init::quartz. More on schema
management, including why the Liquibase variant is attractive only if you already use Liquibase, is on
Job Stores & Persistence and in Schema Evolution (with
Choosing a Migration Tool). If the MongoDB side is already versioned with Mongock
(Getting Started with Mongock), keep it for the collections and indexes only: Mongock does not
manage the Quartz tables and Flyway or Liquibase does not manage MongoDB, so a service in this setup runs two migration tools, each against its own database.
The domain side: documents and repositories
The domain is ordinary Spring Data MongoDB, nothing in it knows about Quartz. A Loan is an immutable record; reminderSentAt is what makes the reminder
job idempotent, and returned is how cancellation is expressed in the source of truth.
package bookshelf.loans;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.Instant;
@Document("loans")
public record Loan(
@Id String id,
@Indexed String memberId,
String isbn,
Instant dueAt,
boolean returned,
Instant reminderSentAt) {
public Loan(String memberId, String isbn, Instant dueAt) {
this(null, memberId, isbn, dueAt, false, null);
}
public Loan markReturned() {
return new Loan(id, memberId, isbn, dueAt, true, reminderSentAt);
}
}
package bookshelf.loans;
import org.springframework.data.mongodb.repository.MongoRepository;
import java.util.List;
public interface LoanRepository extends MongoRepository<Loan, String> {
/** Loans whose reminder still has to be sent. */
List<Loan> findByReturnedFalseAndReminderSentAtIsNull();
}
Member and MemberRepository follow the same pattern and are not shown. See Spring Data MongoDB for
mapping, indexes and repositories.
Creating and cancelling reminders at runtime
All Quartz calls for reminders live in one class, so that the key convention exists in one place. schedule builds a job and a trigger and stores them with
scheduleJob(job, Set.of(trigger), true): the true means replace existing, so calling it twice for the same loan leaves exactly one job and one trigger
(tested). cancel uses deleteJob, which returns false instead of throwing when the job is already gone, so cancelling is also safe to repeat.
The catch in schedule comes from a real race that the test run produced: the reconciliation job and the request that makes the loan can create the same
reminder at the same moment, and with a persistent store the loser of that race gets a JobPersistenceException whose cause is a duplicate key on the primary
key of QRTZ_JOB_DETAILS. The reminder exists in that case, so the method checks checkExists and swallows the exception only when it does.
The misfire instruction withMisfireHandlingInstructionFireNow makes a reminder that was due while the service was down fire as soon as it is back (see
Misfires); the job still checks the loan, so a late reminder for a returned book is skipped.
package bookshelf.loans;
import bookshelf.ReminderProperties;
import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.quartz.JobKey;
import org.quartz.Scheduler;
import org.quartz.SimpleScheduleBuilder;
import org.quartz.SchedulerException;
import org.quartz.Trigger;
import org.quartz.TriggerBuilder;
import org.quartz.impl.matchers.GroupMatcher;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.List;
import java.util.Set;
/** The only class that talks to Quartz about member reminders. Key convention: job and trigger "loan-<loanId>" in group "reminders". */
@Component
public class ReminderScheduler {
public static final String GROUP = "reminders";
private static final String KEY_PREFIX = "loan-";
private final Scheduler scheduler;
private final ReminderProperties properties;
ReminderScheduler(Scheduler scheduler, ReminderProperties properties) {
this.scheduler = scheduler;
this.properties = properties;
}
public static JobKey keyFor(String loanId) {
return JobKey.jobKey(KEY_PREFIX + loanId, GROUP);
}
/** Creates or replaces the reminder for a loan. Calling it twice leaves exactly one job and one trigger. */
public void schedule(Loan loan) throws SchedulerException {
JobDetail job = JobBuilder.newJob(MemberReminderJob.class)
.withIdentity(keyFor(loan.id()))
.usingJobData("loanId", loan.id())
.usingJobData("memberId", loan.memberId())
.build();
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity(KEY_PREFIX + loan.id(), GROUP)
.forJob(job)
.startAt(Date.from(loan.dueAt().minus(properties.lead())))
.withSchedule(SimpleScheduleBuilder.simpleSchedule()
.withMisfireHandlingInstructionFireNow())
.build();
try {
scheduler.scheduleJob(job, Set.of(trigger), true);
} catch (SchedulerException e) {
// LoanService and the reconciliation job can create the same reminder at the same moment; the
// loser gets a duplicate key error from the primary key of QRTZ_JOB_DETAILS. The reminder exists, so that is fine.
if (!exists(loan.id())) {
throw e;
}
}
}
/** Deleting a job that is already gone is fine: the result is false, not an exception. */
public boolean cancel(String loanId) throws SchedulerException {
return scheduler.deleteJob(keyFor(loanId));
}
public boolean exists(String loanId) throws SchedulerException {
return scheduler.checkExists(keyFor(loanId));
}
public List<String> scheduledLoanIds() throws SchedulerException {
return scheduler.getJobKeys(GroupMatcher.jobGroupEquals(GROUP)).stream()
.filter(k -> k.getName().startsWith(KEY_PREFIX))
.map(k -> k.getName().substring(KEY_PREFIX.length()))
.toList();
}
}
The service writes MongoDB first and Quartz second. MongoDB is the source of truth, so a loan is never lost; if the Quartz call fails the loan exists without a reminder, which the reconciliation job repairs. The opposite order would risk a reminder for a loan that was never saved, which the job would at best skip.
package bookshelf.loans;
import org.quartz.SchedulerException;
import org.springframework.stereotype.Service;
import java.time.Instant;
/**
* There is no transaction spanning MongoDB and the Quartz tables. MongoDB is written first (it is the source of truth),
* Quartz second; if the second step fails the reconciliation job repairs it.
*/
@Service
public class LoanService {
private final LoanRepository loans;
private final ReminderScheduler reminders;
LoanService(LoanRepository loans, ReminderScheduler reminders) {
this.loans = loans;
this.reminders = reminders;
}
public Loan lend(String memberId, String isbn, Instant dueAt) throws SchedulerException {
Loan loan = loans.save(new Loan(memberId, isbn, dueAt));
reminders.schedule(loan);
return loan;
}
public void giveBack(String loanId) throws SchedulerException {
loans.findById(loanId).ifPresent(loan -> loans.save(loan.markReturned()));
reminders.cancel(loanId);
}
}
An idempotent job
A Quartz job that touches another system must assume it can run more than once for the same logical event: a node crashes while the job runs and a job that has
requestsRecovery is executed again, an operator triggers it by hand, or the reconciliation job creates a duplicate. MemberReminderJob protects itself
in two ways. It first reads the loan and does nothing if the loan is missing, returned or already reminded. After sending (with the loan id as the idempotency
key for the mail provider) it marks the loan with a conditional update that only matches while reminderSentAt is still null, so a concurrent duplicate
changes nothing. The delivery is therefore at-least-once with a de-duplicating key, which is the honest guarantee when a mail provider call and a MongoDB write
cannot be made atomic. The job gets its collaborators through constructor injection (Boot’s SpringBeanJobFactory autowires job classes) and its data through
setters bound from the JobDataMap, which holds plain strings so that it stays serializable and survives a change of the job class; see
Jobs & Job Data.
package bookshelf.loans;
import org.quartz.JobExecutionContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.scheduling.quartz.QuartzJobBean;
import java.time.Instant;
/**
* Idempotent: it can run twice for the same loan (a retry after a crash, a recovered execution, a duplicate created
* by reconciliation) and the member still receives one reminder.
*/
public class MemberReminderJob extends QuartzJobBean {
private static final Logger log = LoggerFactory.getLogger(MemberReminderJob.class);
private final MongoTemplate mongo;
private final ReminderSender sender;
private String loanId;
private String memberId;
MemberReminderJob(MongoTemplate mongo, ReminderSender sender) {
this.mongo = mongo;
this.sender = sender;
}
public void setLoanId(String loanId) {
this.loanId = loanId;
}
public void setMemberId(String memberId) {
this.memberId = memberId;
}
@Override
protected void executeInternal(JobExecutionContext context) {
Loan loan = mongo.findById(loanId, Loan.class);
if (loan == null || loan.returned() || loan.reminderSentAt() != null) {
log.info("Reminder for loan {} skipped (missing, returned or already sent)", loanId);
return;
}
sender.send(memberId, loanId);
// Only the first writer marks the loan; a concurrent duplicate would update nothing.
mongo.updateFirst(
Query.query(Criteria.where("_id").is(loanId).and("reminderSentAt").is(null)),
Update.update("reminderSentAt", Instant.now()),
Loan.class);
}
}
Reconciliation
The reconciliation job turns the loose coupling into an eventually consistent one. MongoDB wins every disagreement: an active loan without a Quartz job gets one, and a
Quartz job in the reminders group whose loan is returned, already reminded or gone is deleted. It is @DisallowConcurrentExecution so that a slow run is never
overlapped (also across cluster nodes, because the flag is stored with the job), and it reports failures with JobExecutionException(e, false) so that the next tick
simply tries again. This is a polling variant of the transactional outbox pattern: the pending state (returned and reminderSentAt) is part of the loan document, so
it is always written atomically with the business change, and the job derives the scheduling from it.
package bookshelf.loans;
import org.quartz.DisallowConcurrentExecution;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.quartz.QuartzJobBean;
import java.util.HashSet;
import java.util.Set;
/**
* Makes Quartz agree with MongoDB. MongoDB wins: active loans without a Quartz job get one, Quartz jobs whose loan is
* returned, already reminded or gone are deleted.
*/
@DisallowConcurrentExecution
public class ReminderReconciliationJob extends QuartzJobBean {
private static final Logger log = LoggerFactory.getLogger(ReminderReconciliationJob.class);
private final LoanRepository loans;
private final ReminderScheduler reminders;
ReminderReconciliationJob(LoanRepository loans, ReminderScheduler reminders) {
this.loans = loans;
this.reminders = reminders;
}
@Override
protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
try {
Set<String> pending = new HashSet<>();
for (Loan loan : loans.findByReturnedFalseAndReminderSentAtIsNull()) {
pending.add(loan.id());
if (!reminders.exists(loan.id())) {
log.warn("Loan {} had no reminder, scheduling it", loan.id());
reminders.schedule(loan);
}
}
for (String scheduledId : reminders.scheduledLoanIds()) {
if (!pending.contains(scheduledId)) {
log.warn("Reminder for loan {} has no pending loan, deleting it", scheduledId);
reminders.cancel(scheduledId);
}
}
} catch (Exception e) {
// refireImmediately=false: try again on the next tick
throw new JobExecutionException(e, false);
}
}
}
The job itself is the only static Quartz definition on this page, registered as ordinary beans (the per-member reminders are created at runtime). The trigger repeats forever and uses the next-with-remaining-count misfire instruction, so a missed run is simply skipped.
package bookshelf.loans;
import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.quartz.SimpleScheduleBuilder;
import org.quartz.Trigger;
import org.quartz.TriggerBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
/** The one static job of this page: the reconciliation. The per-member reminders are created at runtime. */
@Configuration(proxyBeanMethods = false)
class ReminderScheduleConfig {
@Bean
JobDetail reminderReconciliationJobDetail() {
return JobBuilder.newJob(ReminderReconciliationJob.class)
.withIdentity("reminderReconciliation", "bookshelf")
.storeDurably()
.build();
}
@Bean
Trigger reminderReconciliationTrigger(JobDetail reminderReconciliationJobDetail,
@Value("${bookshelf.reconciliation.interval:PT10M}") Duration interval) {
return TriggerBuilder.newTrigger()
.forJob(reminderReconciliationJobDetail)
.withIdentity("reminderReconciliationTrigger", "bookshelf")
.withSchedule(SimpleScheduleBuilder.simpleSchedule()
.withIntervalInMilliseconds(interval.toMillis())
.repeatForever()
.withMisfireHandlingInstructionNextWithRemainingCount())
.build();
}
}
Run the reconciliation at an interval that matches how long a missing reminder can be tolerated, and keep it much shorter than the reminder lead (ten minutes
against a day here). The scan is a query on two fields of the loans collection; add an index on returned and reminderSentAt when the collection is large.
A grace period (only reconcile loans older than a minute) avoids racing with requests that are still in flight; the race is harmless here because of the tolerant
schedule, but it creates noise in the logs.
What can go wrong, and what handles it
Every row of this table is a window between two steps that cannot be made atomic, together with the mechanism that closes it. The rows marked as tested were produced by the integration tests below; the others follow from the code.
| Failure | Effect | What closes it |
|---|---|---|
The process dies after the loan is saved and before the reminder is scheduled |
A loan without a reminder. |
The reconciliation job schedules it on its next tick (tested: a loan inserted directly into MongoDB gets its reminder and the reminder is sent). |
The process dies after the loan is marked returned and before the reminder is cancelled |
An orphaned reminder. |
The reconciliation job deletes it (tested: the orphan disappears and nothing is sent). If it fires first, the job sees |
The reminder fires and the process dies before |
The member may receive the reminder twice. |
The idempotency key |
The same reminder is scheduled by two callers at once |
One of them fails with a duplicate key error from |
|
The job runs twice for the same loan |
Nothing, by design. |
The job reads the loan first, and the conditional update marks it only once (tested). |
The reminder job fails (the mail provider or MongoDB is unavailable) |
The job throws, the one-shot trigger is finished and Quartz deletes the non-durable job: no retry happens by itself. |
The loan is still pending ( |
PostgreSQL is unavailable |
No trigger is acquired and no reminder can be scheduled or cancelled; the scheduler logs the failures and retries after |
Reminders resume by themselves; missed fire times follow the misfire policy. |
The Testcontainers test
The integration test starts mongo:8 and postgres:17, lets Flyway create the Quartz tables, runs the application with a one-second-resolution configuration (reminder lead of
zero seconds and a reconciliation interval of two seconds) and checks the behaviours of the table above. @DynamicPropertySource supplies the container URLs, so the same
test works on any developer machine or CI runner with Docker. See Spring Boot Integration Tests with Testcontainers
for the general technique, and Testing for how to test Quartz jobs in general.
package bookshelf.loans;
import bookshelf.BookshelfApplication;
import org.junit.jupiter.api.Test;
import org.quartz.Scheduler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.mongodb.MongoDBContainer;
import org.testcontainers.postgresql.PostgreSQLContainer;
import java.time.Duration;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
@Testcontainers
@SpringBootTest(classes = {BookshelfApplication.class, BookshelfRemindersIT.TestConfig.class}, properties = {
"bookshelf.reminders.lead=PT0S",
"bookshelf.reconciliation.interval=PT2S",
"spring.flyway.enabled=${flyway:true}",
"spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.yaml"
})
class BookshelfRemindersIT {
@Container
static final MongoDBContainer MONGO = new MongoDBContainer("mongo:8");
@Container
static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:17");
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.mongodb.uri", () -> MONGO.getReplicaSetUrl("bookshelf"));
registry.add("bookshelf.quartz.datasource.url", POSTGRES::getJdbcUrl);
registry.add("bookshelf.quartz.datasource.username", POSTGRES::getUsername);
registry.add("bookshelf.quartz.datasource.password", POSTGRES::getPassword);
}
static class TestConfig {
@Bean
@org.springframework.context.annotation.Primary
RecordingSender recordingSender() {
return new RecordingSender();
}
}
@Autowired LoanService service;
@Autowired LoanRepository loans;
@Autowired ReminderScheduler reminders;
@Autowired RecordingSender sender;
@Autowired Scheduler scheduler;
@Autowired(required = false)
org.springframework.data.mongodb.core.MongoTemplate mongo;
private int quartzRows(String table) {
try (var c = new org.springframework.jdbc.datasource.SingleConnectionDataSource(
POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword(), true)) {
return new JdbcTemplate(c).queryForObject("select count(*) from " + table, Integer.class);
}
}
@Test
void quartzTablesLiveInPostgresAndLoansInMongo() throws Exception {
assertThat(quartzRows("qrtz_job_details")).isGreaterThanOrEqualTo(1); // the reconciliation job
assertThat(mongo.getCollectionNames()).contains("loans").doesNotContain("qrtz_job_details");
assertThat(scheduler.getMetaData().getJobStoreClass().getSimpleName()).isEqualTo("LocalDataSourceJobStore");
}
@Test
void reminderFiresOnceAndTheLoanIsMarked() throws Exception {
Loan loan = service.lend("m-1", "978-0-13-468599-1", Instant.now().plusSeconds(2));
assertThat(reminders.exists(loan.id())).isTrue();
assertThat(quartzRows("qrtz_triggers")).isGreaterThanOrEqualTo(2);
await().atMost(Duration.ofSeconds(15)).until(() -> sender.sent.contains("m-1:" + loan.id()));
await().atMost(Duration.ofSeconds(5)).until(() -> loans.findById(loan.id()).orElseThrow().reminderSentAt() != null);
assertThat(sender.sent.stream().filter(s -> s.endsWith(loan.id()))).hasSize(1);
}
@Test
void returningTheBookCancelsTheReminder() throws Exception {
Loan loan = service.lend("m-2", "978-0-596-52068-7", Instant.now().plusSeconds(60));
assertThat(reminders.exists(loan.id())).isTrue();
service.giveBack(loan.id());
assertThat(reminders.exists(loan.id())).isFalse();
assertThat(loans.findById(loan.id()).orElseThrow().returned()).isTrue();
assertThat(sender.sent).noneMatch(s -> s.endsWith(loan.id()));
}
@Test
void schedulingTwiceLeavesOneJob() throws Exception {
Loan loan = loans.save(new Loan("m-3", "978-1-4919-5025-7", Instant.now().plusSeconds(3600)));
reminders.schedule(loan);
reminders.schedule(loan);
assertThat(reminders.scheduledLoanIds().stream().filter(loan.id()::equals)).hasSize(1);
assertThat(scheduler.getTriggersOfJob(ReminderScheduler.keyFor(loan.id()))).hasSize(1);
}
@Test
void reconciliationCreatesAMissingReminder() throws Exception {
// MongoDB was written, the process died before Quartz was called
Loan loan = loans.save(new Loan("m-4", "978-0-321-12521-7", Instant.now().plusSeconds(3)));
assertThat(reminders.exists(loan.id())).isFalse();
await().atMost(Duration.ofSeconds(20)).until(() -> sender.sent.contains("m-4:" + loan.id()));
}
@Test
void reconciliationDeletesAnOrphanReminder() throws Exception {
// Quartz was written, the loan was returned in MongoDB and the cancel never happened
Loan loan = loans.save(new Loan("m-5", "978-0-201-63361-0", Instant.now().plusSeconds(3600)));
reminders.schedule(loan);
loans.save(loan.markReturned());
await().atMost(Duration.ofSeconds(20)).until(() -> !reminders.exists(loan.id()));
assertThat(sender.sent).noneMatch(s -> s.endsWith(loan.id()));
}
@Test
void theJobIsIdempotent() throws Exception {
Loan loan = loans.save(new Loan("m-6", "978-0-13-235088-4", Instant.now().plusSeconds(3600)));
reminders.schedule(loan);
var key = ReminderScheduler.keyFor(loan.id());
scheduler.triggerJob(key);
await().atMost(Duration.ofSeconds(10)).until(() -> sender.sent.contains("m-6:" + loan.id()));
await().atMost(Duration.ofSeconds(5)).until(() -> loans.findById(loan.id()).orElseThrow().reminderSentAt() != null);
scheduler.triggerJob(key); // a duplicate run, as after a recovered execution
Thread.sleep(2000);
assertThat(sender.sent.stream().filter(s -> s.endsWith(loan.id()))).hasSize(1);
}
@Test
void aFailedReminderIsRecreatedByReconciliation() throws Exception {
sender.failNext.set(true);
Loan loan = service.lend("m-7", "978-0-13-110362-7", Instant.now().plusSeconds(1));
// the first run fails and the one-shot trigger is finished, so Quartz deletes the non-durable job
await().atMost(Duration.ofSeconds(10)).until(() -> !reminders.exists(loan.id()) || !sender.failNext.get());
// the loan is still pending, so reconciliation schedules it again and the second attempt succeeds
await().atMost(Duration.ofSeconds(30)).until(() -> sender.sent.contains("m-7:" + loan.id()));
assertThat(sender.sent.stream().filter(s -> s.endsWith(loan.id()))).hasSize(1);
}
}
Source: Testcontainers: MongoDB module
The flyway system property in the test properties lets -Dflyway=false switch Flyway off for the Liquibase run. The result was eight tests, no failures, on three consecutive runs of the final code (and once more with Liquibase instead of Flyway). The log of a run shows what each side did:
Flyway created the schema, Quartz started a LocalDataSourceJobStore in clustered mode (Using job-store 'org.springframework.scheduling.quartz.LocalDataSourceJobStore' - which
supports persistence. and is clustered.), the reconciliation job logged had no reminder, scheduling it for the loan that was inserted behind the service’s back and has no
pending loan, deleting it for the orphan, and the idempotent job logged that a repeated run was skipped, and a deliberately failed delivery (the test double throws once) was repeated by the next reconciliation. The assertion that the Quartz tables are in PostgreSQL and the
domain collections are in MongoDB (and not the other way round) is the first test.
The other databases of the site
Couchbase, Neo4j, Elasticsearch and the other NoSQL stores used on this site have no Quartz job store either. They follow exactly this option: keep the data in its own store and give Quartz a small SQL database. Redis has a community store with its own tested result on Redis. Running several service instances against option 1 needs nothing MongoDB-specific; see Clustering.
Option 2: the community quartz-mongodb store, tested and broken on Quartz 2.5.x
This section describes the store so that you can recognise it in a legacy code base and so that the measurements are on the record. It is not a recommendation. The store
is configured through Quartz properties, not through Spring Boot: spring.quartz.job-store-type stays at memory (the default), and org.quartz.jobStore.class and its
properties are passed through spring.quartz.properties, which Boot hands to the SchedulerFactoryBean as Quartz properties. With job-store-type: jdbc and a jobStore.class
set at the same time, Boot’s LocalDataSourceJobStore conflicts with the class and the scheduler fails with DataSource name not set (measured).
spring:
quartz:
scheduler-name: BookshelfMongo
properties:
org.quartz.jobStore.class: com.novemberain.quartz.mongodb.MongoDBJobStore
org.quartz.jobStore.mongoUri: ${MONGODB_URI}
org.quartz.jobStore.dbName: bookshelf
org.quartz.jobStore.collectionPrefix: qrtz
org.quartz.jobStore.isClustered: "true"
org.quartz.jobStore.clusterCheckinInterval: "2000"
org.quartz.scheduler.instanceId: AUTO
org.quartz.threadPool.threadCount: "3"
Source: quartz-mongodb README
The dependency, as it appears on Maven Central (the original Bintray and JCenter coordinates in the README no longer resolve):
<dependency>
<groupId>io.fluidsonic.mirror</groupId>
<artifactId>quartz-mongodb</artifactId>
<version>2.2.0-rc2</version>
</dependency>
The configuration properties
Every property below is a setter on MongoDBJobStore (read from the 2.2.0-rc2 source; Quartz calls the setters by reflection with the values of the
org.quartz.jobStore.* properties). The defaults are the field initialisers in that source.
| Property | Default | Meaning |
|---|---|---|
|
none |
A MongoDB connection string. Alternatively |
|
none |
The database that holds the collections. Required. |
|
|
Prefix of every collection. The setter appends an underscore to the value it receives, so |
|
|
Enables the check-in thread, trigger recovery on start and lock expiry by defunct schedulers. Requires |
|
|
Period of the check-in; a node is defunct when its last check-in plus this interval plus a fixed 7500 ms is in the past. |
|
|
Lateness after which a trigger counts as misfired (applied when the trigger is acquired). |
|
10 minutes each |
Age after which a trigger lock or a job lock is considered expired, whoever owns it. |
|
|
Stores the |
|
majority, |
Write concern of every collection; see below. |
|
|
A |
There are further mongoOption* properties for connection limits, timeouts, TLS trust and key stores. The class is public with a no-argument constructor, which is
how StdSchedulerFactory instantiates a store from org.quartz.jobStore.class.
The collections it creates
On start the store creates five collections in the database named by dbName (with the prefix qrtz of the test configuration; the names below are as listed by mongosh). Two more
collections, <prefix>_paused_job_groups and <prefix>_paused_trigger_groups, appear only when a group is paused.
| Collection | Content |
|---|---|
|
One document per |
|
One document per trigger: |
|
One document per |
|
The lock documents (see the next sections): |
|
One document per scheduler instance with |
The indexes
The store creates the indexes itself (ensureIndexes at start). All of them are unique compound indexes whose first field is the group, because Quartz’s API
searches by group alone; older name-then-group indexes from earlier versions of the store are dropped.
| Collection | Index | Purpose |
|---|---|---|
|
|
One job per key; group lookups. |
|
|
One trigger per key; group lookups. No other index exists, in particular none on |
|
|
The unique index is the lock: a second insert of the same lock fails with a duplicate key error. |
|
|
One check-in document per instance. |
|
|
One calendar per name. |
There is no TTL index anywhere: expiry is decided in Java by comparing the time of a lock document with the clock.
// qrtz_jobs
{ keyName: 'slow', keyGroup: 'bookshelf', jobDescription: null,
jobClass: 'probe.ProbeApplication$SlowJob', durability: true, requestsRecovery: false }
// qrtz_triggers
{ state: 'waiting', class: 'org.quartz.impl.triggers.SimpleTriggerImpl', keyName: 'slowTrigger', keyGroup: 'bookshelf',
jobId: ObjectId('...'), misfireInstruction: 0, nextFireTime: ISODate('2026-10-05T09:57:55.761Z'),
previousFireTime: ISODate('2026-10-05T09:57:54.761Z'), priority: 5, repeatCount: -1, repeatInterval: Long('1000'),
timesTriggered: 1 }
// qrtz_locks, seen while a @DisallowConcurrentExecution job was running
{ type: 't', keyGroup: 'bookshelf', keyName: 'slowTrigger', instanceId: 'host-<millis>' }
{ type: 'j', keyGroup: 'bookshelf', keyName: 'slow', instanceId: 'host-<millis>' }
// qrtz_schedulers
{ schedulerName: 'quartzScheduler', instanceId: 'host-<millis>', checkinInterval: Long('2000'), lastCheckinTime: Long('1791194289929') }
Write concern and replica sets
The store builds its MongoDB connection with WriteConcern.MAJORITY, journaled, with a five-second write timeout, unless mongoOptionWriteConcernW names another
value; every lock insert, trigger update and check-in therefore waits until a majority of a replica set has acknowledged it. The comment in the source gives the
reason: after a failover of the primary, the new primary must still know about the locks and trigger states that were acknowledged. A majority write concern requires a replica
set to be meaningful (a standalone server acknowledges immediately); a production deployment should be a replica set with three data-bearing members, as described on
Replication. The store does not set a read concern or read preference, so reads go to the primary with the driver defaults, which is what
you want for lock state; see Insert and Write Concern. Everything else in the connection (pool size, timeouts, TLS)
is configured through the mongoOption* properties, or by a subclass whose constructor hands a MongoClient to the store.
How its locking differs from JDBC
The JDBC store serializes the critical sections with a row lock on QRTZ_LOCKS: SELECT * FROM QRTZ_LOCKS WHERE SCHED_NAME = ? AND LOCK_NAME = ? FOR UPDATE (class
StdRowLockSemaphore), held until the transaction commits; whoever fails to get it waits, and the database releases the lock by itself when a connection dies. MongoDB has no
equivalent of a blocking row lock, so the community store replaces it by lock documents. The comparison follows from the sources of both stores:
| Aspect | JDBC job store (StdRowLockSemaphore) |
quartz-mongodb (LockManager, LocksDao) |
|---|---|---|
Acquiring a lock |
|
|
What is locked |
Two global lock names ( |
One lock document per acquired trigger (type |
Claiming a trigger |
The store selects the next due trigger and moves it to |
The store reads the eligible triggers with a |
Releasing |
Commit or rollback of the transaction; a crashed node’s connection is closed by the database. |
The owner deletes its lock documents after firing; a crashed owner leaves them behind. |
Detecting a dead node |
|
|
Misfire detection |
A background misfire handler scans |
Applied while triggers are acquired, plus on recovery; there is no separate misfire thread. |
Consequence of a failed check-in |
The failure is logged and the check-in is retried at the next interval. |
The default |
The design is lock-free from the application’s point of view but has two weak spots that the JDBC store does not: a trigger is claimed by a find followed by an insert (not one
atomic operation), and expiry is a comparison of timestamps taken from different machines' clocks, so clock skew between nodes shifts the moment at which a lock counts as expired.
After a hard crash the locks of the dead node are taken over as soon as the node is declared defunct, which is quicker than the ten-minute lock timeouts; a node that is stuck but still checks in keeps its locks for the full timeout.
The tested result: nothing ever fires on Quartz 2.5.x
The same Bookshelf jobs (cache refresh, nightly reminders and a dynamic reminder with job data) were run on the store against mongo:8 with Quartz 2.5.2, 2.4.1 and 2.3.2 and two
MongoDB driver generations. A job counted as working when it fired, a dynamic job with data fired, the persisted job data survived a restart of the application, and a two-node
cluster run produced no duplicate execution.
| Quartz | MongoDB driver | Result |
|---|---|---|
2.5.2 (what Boot 4.1.1 and 3.5.16 manage) |
5.8.1 (Boot-managed) |
Failed, single node and clustered: the scheduler starts, logs that it has started, then no job ever fires. |
2.5.2 |
4.0.5 (what the store was built for) |
Failed in the same way. |
2.4.1 |
5.8.1 |
Passed: jobs fire, the dynamic job fires with its data, the state survives a restart, a two-node cluster has no duplicates. |
2.4.1 |
4.0.5 |
Passed, single node and clustered. |
2.3.2 |
4.0.5 |
Passed, single node and clustered. |
The failure is not an exception in the application. It happens in the scheduler’s own thread the first time a trigger fires:
Exception in thread "quartzScheduler_QuartzSchedulerThread" java.lang.NoSuchMethodError: 'boolean org.quartz.JobDetail.isConcurrentExectionDisallowed()'
at com.novemberain.quartz.mongodb.LockManager.lockJob(LockManager.java:31)
at com.novemberain.quartz.mongodb.TriggerRunner.triggersFired(TriggerRunner.java:89)
at com.novemberain.quartz.mongodb.MongoDBJobStore.triggersFired(MongoDBJobStore.java:488)
at org.quartz.core.QuartzSchedulerThread.run(QuartzSchedulerThread.java:364)
The cause is a spelling fix in Quartz. Up to 2.4.1 the interface org.quartz.JobDetail declared a method with a typo, isConcurrentExectionDisallowed(); in 2.5.2 the
method is spelled isConcurrentExecutionDisallowed() and the old name is gone. This was checked on the jars: javap on quartz-2.3.2.jar and quartz-2.4.1.jar lists only the
misspelled method, and javap on quartz-2.5.2.jar lists only the correct one. The store was compiled against the old name (LockManager.lockJob calls it), so the call
throws a NoSuchMethodError, which is an Error and not an Exception, and the scheduler thread has no handler for it: the thread ends, the scheduler object stays alive, and
scheduler.isStarted() still returns true (also measured). No job runs again until the JVM restarts, and the trigger lock document of the claimed trigger stays in qrtz_locks
(visible in a probe on 2.5.2: one lock of type t and no lock of type j, no execution). Because nothing is logged except that line on standard error, the failure is easy to miss in
a log aggregator; any use of this store with a Quartz upgrade needs a monitor that alerts when no job has fired for longer than its schedule allows, see
Listeners & Plugins and Observability.
Making it work therefore means pinning Quartz at 2.4.1 or lower. Boot exposes the managed version as a property, so <quartz.version>2.4.1</quartz.version> in the POM (or
-Dquartz.version=2.4.1) is enough for Quartz; that is how the passing runs above were produced. The MongoDB driver version is a different matter: the MongoDB BOM is imported rather than
inherited, so a mongodb.version property has no effect and the driver must be pinned in dependencyManagement. Pinning below Boot’s managed version gives up everything added in
Quartz 2.4 and 2.5 and keeps you on a store nobody maintains while Spring Boot moves on, which is why this page keeps it out of the recommendations.
<properties>
<quartz.version>2.4.1</quartz.version>
</properties>
Option 3: a custom JobStore backed by MongoDB
If neither option 1 nor 4 is acceptable, the remaining way to have Quartz persist into MongoDB is to write a store. The contract is the interface
org.quartz.spi.JobStore; Quartz instantiates the class named by
org.quartz.jobStore.class and calls its setters with the org.quartz.jobStore.* properties. In 2.5.2 the interface has 57 abstract methods and two default ones
(getTriggersByJobGroup, getTriggersByTriggerGroup); this section outlines the ones that decide whether a MongoDB implementation is correct, and it shows two
building blocks that were compiled and run. It does not claim that a complete store was written.
| SPI method group | What the MongoDB implementation must provide |
|---|---|
|
Open the client, create indexes, start the cluster check-in thread, and report |
|
CRUD on jobs and triggers with the replace-existing semantics and |
|
Queries with |
|
A trigger state machine (waiting, acquired, executing, paused, blocked, complete, error) and persistent paused-group sets, so that triggers created later in a paused group start paused. |
|
The core: atomically claim due triggers for one scheduler, move them to executing when they fire, and complete them (next fire time, |
|
Tuning hooks: how early the scheduler thread must acquire, and how long to back off (between 20 and 600000 ms) after store failures. |
Around the SPI sit three duties that JDBC gets from the database: misfire handling (find triggers whose next fire time is older than misfireThreshold and call
updateAfterMisfire with the calendar, notifying listeners), cluster membership (check-in documents and recovery of triggers claimed by dead nodes, with requestsRecovery
jobs re-run) and @DisallowConcurrentExecution (a blocked-state per job that survives node failures).
Atomic trigger claim with findOneAndUpdate
MongoDB’s replacement for SELECT … FOR UPDATE on the trigger table is a single-document atomic read-modify-write. findOneAndUpdate changes the state of the earliest due trigger from
waiting to acquired and returns it in one operation, so two schedulers can never get the same document. The sketch also takes over claims that expired (a node died after claiming).
It was run with four concurrent threads on 200 due triggers: 200 distinct triggers were claimed and no trigger twice, and a trigger with an expired claim of a dead node was taken over by
another node.
package jobstoresketch;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.model.FindOneAndUpdateOptions;
import com.mongodb.client.model.Indexes;
import com.mongodb.client.model.ReturnDocument;
import com.mongodb.client.model.Updates;
import org.bson.Document;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import static com.mongodb.client.model.Filters.and;
import static com.mongodb.client.model.Filters.eq;
import static com.mongodb.client.model.Filters.lte;
import static com.mongodb.client.model.Filters.or;
import static com.mongodb.client.model.Sorts.ascending;
/**
* The heart of a MongoDB JobStore, as a sketch: JobStore.acquireNextTriggers without the rest of the SPI.
* One findOneAndUpdate is atomic per document, so two schedulers can never claim the same trigger.
*/
public class MongoTriggerAcquisition {
private final MongoCollection<Document> triggers;
private final String instanceId;
public MongoTriggerAcquisition(MongoCollection<Document> triggers, String instanceId) {
this.triggers = triggers;
this.instanceId = instanceId;
}
public void createIndexes() {
triggers.createIndex(Indexes.compoundIndex(Indexes.ascending("state"), Indexes.ascending("nextFireTime")));
}
/** Claims up to maxCount triggers due before noLaterThan; every claimed trigger is returned exactly once. */
public List<Document> acquireNextTriggers(Instant noLaterThan, int maxCount, Duration claimTimeout) {
List<Document> acquired = new ArrayList<>();
Instant now = Instant.now();
for (int i = 0; i < maxCount; i++) {
Document claimed = triggers.findOneAndUpdate(
and(lte("nextFireTime", Date.from(noLaterThan)),
or(eq("state", "waiting"),
// a node that died after claiming: its claim expired, take the trigger over
and(eq("state", "acquired"), lte("claimExpiresAt", Date.from(now))))),
Updates.combine(
Updates.set("state", "acquired"),
Updates.set("claimedBy", instanceId),
Updates.set("claimExpiresAt", Date.from(now.plus(claimTimeout)))),
new FindOneAndUpdateOptions().sort(ascending("nextFireTime")).returnDocument(ReturnDocument.AFTER));
if (claimed == null) {
break;
}
acquired.add(claimed);
}
return acquired;
}
}
Source: MongoDB manual: findOneAndUpdate
Lock documents with a TTL index
For the global critical sections of a cluster (and for the per-job lock of non-concurrent jobs) the sketch uses lock documents: the unique _id decides who owns the lock, so
insertOne is the lock operation and a duplicate key error means someone else holds it. A TTL index on expiresAt cleans up the locks of crashed owners, but a TTL index is not
a timer: MongoDB’s TTL monitor runs about once a minute (an expired document was deleted 58 seconds after its expiry time in this run). Correctness therefore cannot depend on it. The
sketch also deletes an expired lock explicitly before inserting (tested: a lock that had expired a fraction of a second ago was taken over immediately by another node, long before the TTL
monitor would have removed it).
package jobstoresketch;
import com.mongodb.ErrorCategory;
import com.mongodb.MongoWriteException;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.model.IndexOptions;
import com.mongodb.client.model.Indexes;
import org.bson.Document;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import java.util.concurrent.TimeUnit;
import static com.mongodb.client.model.Filters.and;
import static com.mongodb.client.model.Filters.eq;
import static com.mongodb.client.model.Filters.lte;
/**
* Lock documents with an expiry, the Mongo answer to QRTZ_LOCKS. The unique _id decides who owns a lock,
* the TTL index only cleans up after crashed owners.
*/
public class MongoLocks {
private final MongoCollection<Document> locks;
public MongoLocks(MongoCollection<Document> locks) {
this.locks = locks;
}
public void createIndexes() {
locks.createIndex(Indexes.ascending("expiresAt"), new IndexOptions().expireAfter(0L, TimeUnit.SECONDS));
}
public boolean tryLock(String name, String owner, Duration timeout) {
Instant now = Instant.now();
// the TTL monitor only runs about once a minute, so an expired lock is also removed here
locks.deleteOne(and(eq("_id", name), lte("expiresAt", Date.from(now))));
try {
locks.insertOne(new Document("_id", name).append("owner", owner)
.append("expiresAt", Date.from(now.plus(timeout))));
return true;
} catch (MongoWriteException e) {
if (e.getError().getCategory() == ErrorCategory.DUPLICATE_KEY) {
return false;
}
throw e;
}
}
public void unlock(String name, String owner) {
locks.deleteOne(and(eq("_id", name), eq("owner", owner)));
}
}
Source: MongoDB manual: TTL indexes
The remaining pieces follow the same pattern. The misfire scan is a query on state=waiting and nextFireTime older than the threshold followed by updateAfterMisfire and a
conditional update (compare-and-set on nextFireTime). The cluster check-in is an upserted document per instance updated by a scheduled thread (a one-thread executor and an upsert
update, as the community store does), with failed-node detection by comparing lastCheckinTime plus the interval with the clock, then recovery of the claimed triggers of the dead node.
Multi-document operations (store job and trigger, remove job with its triggers) need a replica set and a transaction, with the limits on transaction size and duration from
Transactions.
Why this is rarely worth it: the community store is about 4,700 lines of Java for roughly this scope; every Quartz upgrade can change the contract (option 2 died on a method rename); the JDBC store relies on the locking of mature relational databases; and a store you write must be tested under failure (killed nodes, clock skew, primary failover) which is more work than all the rest of the Quartz integration together. Choose it only when a team will own it for the life of the system.
Option 4: no Quartz, @Scheduled plus ShedLock with MongoDB
When the requirement is only "run this every night on exactly one instance", Quartz is more machinery than needed. Spring’s @Scheduled decides when, and ShedLock decides who
by inserting a lock document into MongoDB. This is the right choice for the nightly overdue-loan reminder when persistence of schedules, misfire policies and runtime scheduling are not
needed; it is the wrong one for the per-member reminders, which are created and cancelled while the service runs. Scheduling & ShedLock
explains ShedLock in general (lock names, lockAtMostFor, lockAtLeastFor, the other providers) and Standard Scheduling vs
Quartz compares the two approaches feature by feature; this section adds the MongoDB provider, compiled and run.
The provider lives in net.javacrumbs.shedlock:shedlock-provider-mongo, next to shedlock-spring (version 7.10.1 for both), and its class is
net.javacrumbs.shedlock.provider.mongo.MongoLockProvider; it takes a MongoDatabase (or a MongoCollection<Document>) of the synchronous driver. A separate artifact,
shedlock-provider-mongo-reactivestreams, exists for the reactive driver.
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
<version>7.10.1</version>
</dependency>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-mongo</artifactId>
<version>7.10.1</version>
</dependency>
Source: ShedLock README: MongoDB
package shedlockdemo;
import net.javacrumbs.shedlock.core.LockProvider;
import net.javacrumbs.shedlock.provider.mongo.MongoLockProvider;
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.MongoDatabaseFactory;
import org.springframework.scheduling.annotation.EnableScheduling;
/** Option 4: no Quartz. Spring's @Scheduled decides when, a lock document in MongoDB decides who. */
@Configuration(proxyBeanMethods = false)
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "PT10M")
public class OverdueRemindersLockConfig {
@Bean
LockProvider lockProvider(MongoDatabaseFactory databaseFactory) {
return new MongoLockProvider(databaseFactory.getMongoDatabase());
}
}
Source: ShedLock README: MongoDB
package shedlockdemo;
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.concurrent.atomic.AtomicInteger;
@Component
public class OverdueLoanReminders {
private final AtomicInteger runs = new AtomicInteger();
public int runs() {
return runs.get();
}
// 02:00 Europe/Madrid, Monday to Friday; the lock name is the key of the document in the shedLock collection
@Scheduled(cron = "${bookshelf.overdue.cron:0 0 2 * * MON-FRI}", zone = "Europe/Madrid")
@SchedulerLock(name = "overdueLoanReminders", lockAtMostFor = "PT30M", lockAtLeastFor = "PT1M")
public void sendOverdueReminders() {
runs.incrementAndGet();
// query the overdue loans in MongoDB and send one reminder per member
}
}
To check it, the test starts two application contexts against the same MongoDB (mongo:8), switches the cron to every second and counts executions over four and a half seconds with a one
minute lockAtLeastFor: across the two nodes the method ran exactly once. The lock is one document in the shedLock collection (the provider’s default collection name) and no index other
than the default _id index exists, because the lock is the unique _id:
{ _id: 'overdueLoanReminders',
lockUntil: ISODate('2026-10-05T10:02:39.013Z'),
lockedAt: ISODate('2026-10-05T10:01:39.016Z'),
lockedBy: '<hostname>' }
Source: ShedLock README: MongoDB
What this option does not give you is what Quartz is for. A schedule exists only in code, so a job cannot be created for a loan at runtime and cancelled later; nothing is remembered across
restarts, so a nightly run missed because the service was down is simply not run (no misfire policy; see Misfires); and lockAtLeastFor and
lockAtMostFor bound, but do not guarantee, mutual exclusion (a job that runs longer than lockAtMostFor can overlap with the next node). Use option 1 as soon as one of those matters.
Choosing, by Bookshelf job
| Bookshelf job | Option | Reason |
|---|---|---|
Cache refresh every 5 minutes |
None, plain |
Every instance needs its own fresh cache; nothing to lock or persist. |
Nightly overdue-loan reminders |
Option 4, or option 1 with a |
Option 4 when one run per night on one instance is enough; option 1 when a holiday calendar and a misfire policy are needed. |
Per-member reminders, created and cancelled at runtime |
Option 1 |
Needs persistent, dynamic triggers and job data; this is what the example above implements. |
Weekly statistics report, not overlapping, recovered after a crash |
Option 1 |
|
Monthly retention cleanup on the last business day |
Option 1 |
|
Pitfalls
Every row was reproduced in the test projects unless it says it follows from the source.
| Pitfall | What you see | Fix |
|---|---|---|
Using the community store on Quartz 2.5.x (Boot 4.1 or 3.5) |
The scheduler logs that it has started, |
Do not use it. Use option 1 or option 4; if you must, pin Quartz at 2.4.1 or lower and monitor that jobs actually fire. |
|
Collections named |
Give the prefix without the underscore ( |
|
The scheduler fails to start with |
Leave |
Several JDBC data sources and none marked |
No error; Boot silently uses |
Mark the application’s data source |
|
The script begins with |
Use |
Writing MongoDB and Quartz in the wrong order, or assuming a shared transaction |
A reminder for a loan that does not exist, or a loan without reminder after a crash. |
MongoDB first, Quartz second, and a reconciliation job; there is no transaction across the two. |
A job that is not idempotent |
The member gets two reminders after a recovered execution or a manual trigger. |
Read the state first, send with an idempotency key, and mark with a conditional update. |
Two callers schedule the same reminder at once |
|
Catch it, check |
Putting MongoDB types in the |
With the JDBC store the map is serialized (a BLOB); an |
Store plain strings ( |
Relying on a TTL index to expire locks |
A lock outlives its expiry by up to a minute (58 seconds measured). |
Compare the expiry timestamp in the lock query and delete expired locks explicitly. |
The default |
A node whose check-in fails calls |
Know it exists; set |
|
No effect: the MongoDB BOM is imported, not inherited. |
Pin the driver in |
|
Another node starts the job while the first is still running (follows from the ShedLock contract). |
Set |
What changed
-
Quartz 2.5. The typo in
JobDetail.isConcurrentExectionDisallowed()was fixed (isConcurrentExecutionDisallowed()), which is what broke every store compiled against 2.4 or earlier that calls it, the community MongoDB store among them. Quartz 2.3.2 and 2.4.1 have only the old name, 2.5.2 only the new one (checked on the jars). Custom stores and delegates must be recompiled. -
Quartz 2.4. The Terracotta job store was removed; JDBC is the only clustered store, so a MongoDB-based cluster means a community or custom store.
-
The community store. The upstream repository was archived after its last push in November 2022; the 2021 release candidate
2.2.0-rc2on the Maven Central mirror is the last version, built for Quartz 2.3.2. -
Spring Boot 3.5 to 4.x.
@QuartzDataSourcemoved toorg.springframework.boot.quartz.autoconfigure,DataSourcePropertiestoorg.springframework.boot.jdbc.autoconfigure, the MongoDB URI property fromspring.data.mongodb.uritospring.mongodb.uri, and Flyway and Liquibase have their own starters. Both Boot lines manage Quartz 2.5.2, so the conclusions about the store do not depend on the Boot line. -
Testcontainers 2. The MongoDB module is
org.testcontainers:testcontainers-mongodbwithorg.testcontainers.mongodb.MongoDBContainer; the 1.x class inorg.testcontainers.containersis its predecessor. The full list of version changes is on Migration & What Changed.