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:

Table 1. The four ways to combine Quartz (or scheduling in general) with MongoDB
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 quartz-mongodb store

Everything in MongoDB, no SQL database.

Broken: the scheduler thread dies with a NoSuchMethodError, nothing fires, no error reaches the application. Works only on Quartz 2.4.1 and 2.3.2.

Not recommended: it means overriding quartz.version below what Boot manages and giving up the 2.5 features.

3. Custom JobStore

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: @Scheduled plus ShedLock

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.

Two panels side by side. Left panel, option 1, recommended: the bookshelf-service uses Spring Data MongoDB to reach a MongoDB database that holds the loans and members collections, the source of truth, and uses a dedicated Quartz data source to reach a PostgreSQL database that holds only the QRTZ tables, with the schema owned by Flyway. A red dashed line with a cross between the two databases states that no transaction spans them. A box below lists four consistency rules: write MongoDB first and Quartz second, make the reminder job idempotent, run a reconciliation job that creates missing reminders and deletes orphaned ones, and schedule with the same keys and replace set to true so scheduling twice is harmless. Right panel, option 2, the community store: the service sets job-store-type to memory and org.quartz.jobStore.class in spring.quartz.properties, the QuartzScheduler uses the MongoDBJobStore, which keeps the qrtz jobs, triggers, calendars, locks and schedulers collections in MongoDB. A dashed red box states the measured result on Quartz 2.5.2: the scheduler thread dies with a NoSuchMethodError on the misspelled method JobDetail.isConcurrentExectionDisallowed, nothing fires, the scheduler still reports started, and it works on Quartz 2.4.1 and 2.3.2
Figure 1. Option 1 keeps Quartz’s state in SQL and the domain in MongoDB; option 2 puts Quartz’s state in MongoDB and does not work on 2.5.2
flowchart TD A["Do you need to run something on a schedule in a service whose data lives in MongoDB?"] --> B{"Must scheduled work survive a restart, with misfire policies, or be created and cancelled at runtime?"} B -->|no| C["Option 4: @Scheduled plus ShedLock MongoLockProvider. A lock document in MongoDB makes each tick run on one instance"] B -->|yes| D{"Can you run a small SQL database next to MongoDB?"} D -->|yes| E["Option 1: Quartz on SQL, business data in MongoDB. Make jobs idempotent and reconcile"] D -->|no| F{"Can you pin Quartz at 2.4.1 or lower and accept an archived store?"} F -->|yes| G["Option 2: community quartz-mongodb. Tested on 2.4.1 and 2.3.2, broken on 2.5.x. Own the risk"] F -->|no| H{"Is a team ready to write and maintain a JobStore with its own concurrency tests?"} H -->|yes| I["Option 3: custom JobStore backed by MongoDB. Atomic claims, lock documents, check-in, recovery"] H -->|no| J["Reconsider: an embedded database for Quartz, a managed SQL service, or option 4"]
Figure 2. Which option fits which situation

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.

sequenceDiagram participant U as Librarian participant S as LoanService participant M as MongoDB participant Q as Quartz in PostgreSQL participant J as MemberReminderJob participant R as ReminderReconciliationJob U->>S: lend(member, book, dueAt) S->>M: save Loan, the source of truth S->>Q: scheduleJob loan-id with replace true Note over S,Q: a crash between these two writes leaves a loan without a reminder Q-->>J: trigger fires at dueAt minus lead J->>M: load Loan alt returned, missing or already reminded J-->>Q: do nothing else still pending J->>J: send reminder with idempotency key loan-id J->>M: set reminderSentAt only if still null end U->>S: giveBack(loan) S->>M: mark returned S->>Q: deleteJob loan-id Note over S,Q: a crash between these two writes leaves an orphan reminder R->>M: find loans not returned and not reminded R->>Q: create the missing reminders R->>Q: delete reminders without a pending loan
Figure 3. What happens when a loan is made, returned or forgotten

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).

pom.xml: Quartz, MongoDB and PostgreSQL side by side
<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.

QuartzDatabaseConfig.java: a pool that only Quartz uses
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.

application.yml: MongoDB for the domain, a separate PostgreSQL for Quartz
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.

src/main/resources/db/migration/V1__quartz_tables.sql: tables_postgres.sql without the DROP statements (first lines, 11 CREATE TABLE statements in 196 lines in total)
-- 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.

src/main/resources/db/changelog/db.changelog-master.yaml: including the changelog that ships with Quartz
databaseChangeLog:
  - include:
      file: org/quartz/impl/jdbcjobstore/liquibase.quartz.init.xml
application.yml fragment for the Liquibase variant
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.

Loan.java and LoanRepository.java: 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.

ReminderScheduler.java: the only class that talks to Quartz about reminders
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.

LoanService.java: MongoDB first, Quartz second
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.

MemberReminderJob.java: safe to run twice
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.

ReminderReconciliationJob.java: MongoDB wins
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.

ReminderScheduleConfig.java: the one static job and trigger
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.

Table 2. Failure windows of option 1
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 returned and does nothing.

The reminder fires and the process dies before reminderSentAt is written

The member may receive the reminder twice.

The idempotency key loan-<loanId> lets the mail provider drop the duplicate; the job marks the loan only once.

The same reminder is scheduled by two callers at once

One of them fails with a duplicate key error from QRTZ_JOB_DETAILS.

schedule re-checks checkExists and accepts the failure when the reminder exists (the race was observed during the tests).

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 (reminderSentAt is null), so the reconciliation job creates the reminder again and it fires immediately (tested with a delivery that fails once).

PostgreSQL is unavailable

No trigger is acquired and no reminder can be scheduled or cancelled; the scheduler logs the failures and retries after dbRetryInterval.

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.

BookshelfRemindersIT.java: eight tests against real MongoDB and PostgreSQL
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);
    }
}

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).

application.yml: configuring the community store (the settings that were run)
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"

The dependency, as it appears on Maven Central (the original Bintray and JCenter coordinates in the README no longer resolve):

pom.xml: the only copy on Maven Central is an unofficial mirror
<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.

Table 3. The main properties of MongoDBJobStore
Property Default Meaning

mongoUri

none

A MongoDB connection string. Alternatively addresses (comma-separated seeds) with username, password and authDbName.

dbName

none

The database that holds the collections. Required.

collectionPrefix

quartz_

Prefix of every collection. The setter appends an underscore to the value it receives, so qrtz gives qrtz_jobs and qrtz_ gives qrtz__jobs (measured).

isClustered

false

Enables the check-in thread, trigger recovery on start and lock expiry by defunct schedulers. Requires org.quartz.scheduler.instanceId=AUTO or distinct ids.

clusterCheckinInterval

7500 ms

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.

misfireThreshold

5000 ms

Lateness after which a trigger counts as misfired (applied when the trigger is acquired).

triggerTimeoutMillis, jobTimeoutMillis

10 minutes each

Age after which a trigger lock or a job lock is considered expired, whoever owns it.

jobDataAsBase64

true

Stores the JobDataMap as Base64 text of the Java serialization instead of a plain sub-document.

mongoOptionWriteConcernW, mongoOptionWriteConcernTimeoutMillis

majority, 5000 ms

Write concern of every collection; see below.

checkInErrorHandler

KamikazeErrorHandler

A Runnable class run when a check-in fails. The default calls System.exit(1).

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.

Table 4. Collections created by the store (listed with mongosh after a run on Quartz 2.4.1)
Collection Content

qrtz_jobs

One document per JobDetail: keyName, keyGroup, jobDescription, jobClass, durability, requestsRecovery and the job data.

qrtz_triggers

One document per trigger: state (waiting, paused, pausedBlocked, blocked, complete, deleted or error), class, keyName, keyGroup, jobId (the _id of the job document), nextFireTime, previousFireTime, startTime, misfireInstruction, priority, and fields specific to the trigger type (a simple trigger has repeatInterval and repeatCount).

qrtz_calendars

One document per Calendar, serialized, with a unique name.

qrtz_locks

The lock documents (see the next sections): type (t for a trigger lock, j for a job lock), keyGroup, keyName, instanceId and time.

qrtz_schedulers

One document per scheduler instance with schedulerName, instanceId, checkinInterval and lastCheckinTime: the cluster membership list.

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.

Table 5. Indexes reported by getIndexes() on the collections (besides the default _id index)
Collection Index Purpose

qrtz_jobs

{ keyGroup: 1, keyName: 1 }, unique

One job per key; group lookups.

qrtz_triggers

{ keyGroup: 1, keyName: 1 }, unique

One trigger per key; group lookups. No other index exists, in particular none on state and nextFireTime.

qrtz_locks

{ keyGroup: 1, keyName: 1, type: 1 }, unique

The unique index is the lock: a second insert of the same lock fails with a duplicate key error.

qrtz_schedulers

{ schedulerName: 1, instanceId: 1 }, unique

One check-in document per instance.

qrtz_calendars

{ name: 1 }, unique

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.

Documents as they were stored on Quartz 2.4.1 (mongosh, instance ids shortened)
// 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:

Table 6. Row locks in JDBC against lock documents in the community store
Aspect JDBC job store (StdRowLockSemaphore) quartz-mongodb (LockManager, LocksDao)

Acquiring a lock

SELECT …​ FOR UPDATE on a row of QRTZ_LOCKS; blocks until the holder commits.

insertOne of a lock document; the unique index on group, name and type makes a second insert fail with a duplicate key error, and the caller moves on without waiting.

What is locked

Two global lock names (TRIGGER_ACCESS, STATE_ACCESS) around state changes, and around trigger acquisition when acquireTriggersWithinLock is set or the batch is larger than one trigger.

One lock document per acquired trigger (type t) and, for @DisallowConcurrentExecution jobs, per running job (type j). There is no global lock.

Claiming a trigger

The store selects the next due trigger and moves it to ACQUIRED with an UPDATE that succeeds only while the state is still WAITING (JobStoreSupport.acquireNextTrigger).

The store reads the eligible triggers with a find sorted by nextFireTime and then tries to insert the trigger lock; the first node to insert wins, the others skip that trigger.

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

QRTZ_SCHEDULER_STATE check-ins; another node recovers the dead node’s fired triggers.

qrtz_schedulers check-ins; a lock is expired when its time is older than triggerTimeoutMillis (or jobTimeoutMillis) or when its owner is defunct, and another node then re-locks the trigger with a conditional update and recovers it.

Misfire detection

A background misfire handler scans QRTZ_TRIGGERS.

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 KamikazeErrorHandler calls System.exit(1), so that a node other nodes may have declared defunct cannot go on executing jobs.

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.

Table 7. quartz-mongodb 2.2.0-rc2 against Quartz and driver versions (MongoDB 8.3.11)
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:

Standard error on Quartz 2.5.2 (the only trace of the failure)
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.

pom.xml: what was run to make the store work (not recommended)
<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.

Table 8. The JobStore methods and what a MongoDB implementation must do for them
SPI method group What the MongoDB implementation must provide

initialize, schedulerStarted, shutdown, setInstanceId, setInstanceName, setThreadPoolSize, supportsPersistence, isClustered

Open the client, create indexes, start the cluster check-in thread, and report supportsPersistence()=true and isClustered() honestly: Quartz changes its recovery behaviour on them.

storeJob, storeTrigger, storeJobAndTrigger, storeJobsAndTriggers, replaceTrigger, removeJob(s), removeTrigger(s), retrieveJob, retrieveTrigger, checkExists, clearAllSchedulingData

CRUD on jobs and triggers with the replace-existing semantics and ObjectAlreadyExistsException. storeJobsAndTriggers and the removal of a job together with its triggers are multi-document operations: without a transaction a crash leaves a trigger without a job.

storeCalendar, removeCalendar, retrieveCalendar, getCalendarNames, getNumberOf*, getJobGroupNames, getTriggerGroupNames, getJobKeys, getTriggerKeys, getTriggersForJob, getTriggersBy*, getJobDetails

Queries with GroupMatcher support (equals, startsWith, endsWith, contains, anything), which is why the community store indexes the group first.

pauseTrigger(s), pauseJob(s), resumeTrigger(s), resumeJob(s), pauseAll, resumeAll, getPausedTriggerGroups, getTriggerState, resetTriggerFromErrorState

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.

acquireNextTriggers, releaseAcquiredTrigger, triggersFired, triggeredJobComplete

The core: atomically claim due triggers for one scheduler, move them to executing when they fire, and complete them (next fire time, COMPLETE, error) when the job ends. This is where the concurrency bugs live.

getEstimatedTimeToReleaseAndAcquireTrigger, getAcquireRetryDelay

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.

MongoTriggerAcquisition.java: the heart of acquireNextTriggers (sketch, compiled and run)
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;
    }
}

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).

MongoLocks.java: lock documents with an expiry (sketch, compiled and run)
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)));
    }
}

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.

pom.xml: ShedLock with the MongoDB provider
<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>
OverdueRemindersLockConfig.java: one lock provider for the whole application
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());
    }
}
OverdueLoanReminders.java: the nightly job, cron at 02:00 Europe/Madrid on business days
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:

The lock document after the run (host name replaced by a placeholder)
{ _id: 'overdueLoanReminders',
  lockUntil: ISODate('2026-10-05T10:02:39.013Z'),
  lockedAt: ISODate('2026-10-05T10:01:39.016Z'),
  lockedBy: '<hostname>' }

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

Table 9. Which option each Bookshelf job gets when the domain data is in MongoDB
Bookshelf job Option Reason

Cache refresh every 5 minutes

None, plain @Scheduled(fixedDelay)

Every instance needs its own fresh cache; nothing to lock or persist.

Nightly overdue-loan reminders

Option 4, or option 1 with a CronTrigger

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

@DisallowConcurrentExecution with requestsRecovery and clustering need the JDBC store.

Monthly retention cleanup on the last business day

Option 1

L/LW cron and a calendar; launches a Spring Batch job.

Pitfalls

Every row was reproduced in the test projects unless it says it follows from the source.

Table 10. Common problems when Quartz meets MongoDB
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, isStarted() is true, no job fires, and the only trace is a NoSuchMethodError on standard error from the scheduler thread.

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.

collectionPrefix with a trailing underscore

Collections named qrtz__jobs: the setter appends _ itself.

Give the prefix without the underscore (qrtz).

spring.quartz.job-store-type: jdbc together with org.quartz.jobStore.class

The scheduler fails to start with DataSource name not set.

Leave job-store-type at memory for a non-JDBC store, as in option 2.

Several JDBC data sources and none marked @Primary

No error; Boot silently uses RAMJobStore and every job disappears at restart.

Mark the application’s data source @Primary and the Quartz one @QuartzDataSource; @QuartzDataSource alone does not help when there is no @Primary.

initialize-schema: always on the Quartz database

The script begins with DROP TABLE IF EXISTS, so every restart deletes all stored reminders.

Use never with Flyway or Liquibase, as in option 1.

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

JobPersistenceException caused by a duplicate key on QRTZ_JOB_DETAILS_PKEY (observed).

Catch it, check checkExists, and treat an existing reminder as success.

Putting MongoDB types in the JobDataMap

With the JDBC store the map is serialized (a BLOB); an ObjectId or a document breaks when classes change.

Store plain strings (loanId, memberId) and load the document inside the job.

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 KamikazeErrorHandler of the community store

A node whose check-in fails calls System.exit(1).

Know it exists; set checkInErrorHandler to your own Runnable if the store is used at all.

mongodb.version property to pin the driver

No effect: the MongoDB BOM is imported, not inherited.

Pin the driver in dependencyManagement; quartz.version can be overridden as a property.

lockAtMostFor shorter than the job (option 4)

Another node starts the job while the first is still running (follows from the ShedLock contract).

Set lockAtMostFor above the longest run; see Scheduling & ShedLock.

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-rc2 on the Maven Central mirror is the last version, built for Quartz 2.3.2.

  • Spring Boot 3.5 to 4.x. @QuartzDataSource moved to org.springframework.boot.quartz.autoconfigure, DataSourceProperties to org.springframework.boot.jdbc.autoconfigure, the MongoDB URI property from spring.data.mongodb.uri to spring.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-mongodb with org.testcontainers.mongodb.MongoDBContainer; the 1.x class in org.testcontainers.containers is its predecessor. The full list of version changes is on Migration & What Changed.