Quartz with Redis

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 has no official Redis job store. The only one that exists is a community project, net.joelinn:quartz-redis-jobstore, and this page documents it as it really behaves today: what it keeps in Redis, how it locks, how it is configured, which versions of Quartz and Jedis it can run with (almost none of the current ones), what survives when Redis dies and what happens to a running job when a node dies. It then spends the rest of its length on the options that do work with the current baseline: keep Quartz on a SQL database and use Redis for the application data, or drop Quartz for a distributed @Scheduled with ShedLock’s Redis lock provider. The generic job store concepts are on Job Stores & Persistence, the same decision for MongoDB is on Quartz with MongoDB, and the reference JDBC page is Quartz with PostgreSQL.

It was written against Quartz 2.5.2, Spring Boot 4.1.1, Spring Framework 7.0.9 and Java 21 (compiled for 21, run on JDK 26.0.1 with Maven 3.9.16) and verified on 5 October 2026. Redis was redis:8 (Redis 8.10.2) and, for the SQL alternative, postgres:17 (PostgreSQL 17.11), both started with Testcontainers 2.0.5 on Docker Desktop for Apple Silicon. The store was tested with Jedis 3.3.0, the version its own POM declares, and with Boot’s managed Jedis 7.4.1. Every statement of the form "works" or "fails" below was produced by a test run on those versions and the failures are quoted verbatim. Spring Boot 3.5.16 manages the same Quartz 2.5.2 (and Jedis 6.0.0), so the store fails there for the same reasons; the Boot 3.5 differences (package names of @QuartzDataSource and friends, no Flyway or Liquibase starter) are called out where they appear and collected in What changed.

Status: a community store that stopped at Quartz 2.2/2.3

The facts about the project, read from its POM and from GitHub on 5 October 2026:

Table 1. Status of the Redis job store
Question Answer

Coordinates

net.joelinn:quartz-redis-jobstore:1.2.0 on Maven Central. Upstream is jlinn/quartz-redis-jobstore.

Maintained?

Not archived (unlike the MongoDB store). The last push to the repository was on 18 April 2024; the artifact on Central was published in March 2021 (POM dated 2021-03-12) and the only GitHub release tag is 1.1.13 from 2018.

Built against

Its POM declares Quartz 2.2.1 (a property, quartz.version), Jedis 3.3.0, Jackson 2.11.1 and SLF4J 1.7.7. It was never rebuilt for Quartz 2.4 or 2.5.

Official?

No. The Quartz 2.5.2 jar contains RAMJobStore and the JDBC job stores (JobStoreTX, JobStoreCMT) and nothing for Redis; Spring Boot’s spring.quartz.job-store-type only knows memory and jdbc.

Features

Persistence, a cluster mode (always on), misfire handling, calendars (a Jackson mixin exists for HolidayCalendar only; other calendar types were not tested), Redis Cluster and Redis Sentinel connections.

Not implemented

requestsRecovery (the string recover does not occur anywhere in the store’s six main source files), Redis ACL user names (there is a password setter and no user setter), TLS settings beyond a boolean, and any configuration of connection timeouts.

Some descriptions call the store "Redlock-based"; the source shows something simpler, described in How it locks.

Tested compatibility

The matrix below was produced by running the same Bookshelf scenario (the five jobs, a dynamic per-member reminder with job data, a restart without wiping Redis, and a two-scheduler cluster run that checks no fire time executes twice) against redis:8 for each combination, by overriding the Maven properties quartz.version and jedis.version of the Spring Boot parent. The four combinations of the first two Quartz versions with the two interesting Jedis versions were run a second time in a clean project for this page and gave the same results.

Table 2. Quartz and Jedis versions against quartz-redis-jobstore 1.2.0 (Redis 8.10.2)
Quartz Jedis Result Where

2.5.2 (managed by Boot 4.1.1 and 3.5.16)

7.4.1 (managed by Boot 4.1.1)

Fails at startup. SchedulerException: Registration of jobs and triggers failed: 'java.lang.Long redis.clients.jedis.commands.JedisCommands.del(java.lang.String)'

Matrix run and clean re-run

2.5.2

3.3.0 (the version the store was built for)

Fails. The scheduler starts, then the first trigger scan throws JobPersistenceException: Could not acquire next triggers caused by Jackson Unrecognized field "concurrentExecutionDisallowed" (class org.quartz.impl.JobDetailImpl), and the scheduler thread dies with AbstractMethodError: Method net/joelinn/quartz/jobstore/RedisJobStore.getAcquireRetryDelay(I)J is abstract. Nothing fires.

Matrix run and clean re-run

2.4.1

7.4.1

Fails at startup, same JedisCommands.del(java.lang.String) error as the first row

Matrix run and clean re-run

2.4.1

4.4.8, 5.2.0 and 6.2.0

Fails at startup, the same JedisCommands.del(java.lang.String) error for every Jedis release after 3.3.0 that was tried

Matrix run

2.4.1

3.3.0

Works: cache refresh and overdue-reminder jobs fire, a job scheduled at runtime fires with its data, two schedulers share the work with no fire time executed twice (22 keys under the prefix in the full scenario). The restart check passed in one run and failed in another (see Fail-over for the likely reason).

Matrix runs and clean re-run of the Spring Boot test shown below

2.3.2

3.3.0

Cluster run passes; the single-node restart check failed in the run made (the job-data counter did not advance after the restart), so call it inconsistent

Matrix run

Three independent breakages explain the table, and each is visible in the store’s source:

  • Jedis. The store is compiled against redis.clients.jedis.commands.JedisCommands of Jedis 3.3.0 and calls del(String) on it. In every later Jedis major that was tried (4.4.8 up to Boot’s 7.4.1) the method no longer exists with that return type on that interface, hence the NoSuchMethodError. Boot’s dependency management pins Jedis, and the store’s POM 3.3.0 loses against it.

  • Jackson mixin. JobDetailMixin ignores isConcurrentExectionDisallowed() (the misspelling was in Quartz up to 2.4). Quartz 2.5 removed that method and added isConcurrentExecutionDisallowed(), so the property is now serialized as concurrentExecutionDisallowed and cannot be read back into JobDetailImpl.

  • The JobStore SPI. Quartz 2.5.2 added long getAcquireRetryDelay(int failureCount) to org.quartz.spi.JobStore as an abstract method. A store compiled earlier does not implement it, which is the AbstractMethodError. Anyone writing a custom store (see the option on the MongoDB page) has to implement it.

So the practical answer on the Spring Boot 4.1 and 3.5 baseline is: the community Redis store cannot be used. The only working combination, Quartz 2.4.1 with Jedis 3.3.0, means overriding two managed versions of Boot (and of anything else on the classpath that uses Jedis), losing every Quartz 2.5 feature and bug fix, and depending on a library that has not been released since 2021. The next section shows that combination because it is the only way to see the store working, to test the claims of this page and to make an informed decision, not because it is recommended.

The one combination that works

The following project was run for this page. Both pins are required: quartz.version (Boot’s parent exposes it as a property) and jedis.version.

pom.xml: Boot 4.1.1 with Quartz 2.4.1 and Jedis 3.3.0 pinned
<properties>
  <java.version>21</java.version>
  <testcontainers.version>2.0.5</testcontainers.version>
  <!-- both pins are required by quartz-redis-jobstore 1.2.0 -->
  <quartz.version>2.4.1</quartz.version>
  <jedis.version>3.3.0</jedis.version>
</properties>

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-quartz</artifactId>
  </dependency>
  <dependency>
    <groupId>net.joelinn</groupId>
    <artifactId>quartz-redis-jobstore</artifactId>
    <version>1.2.0</version>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers-junit-jupiter</artifactId>
    <version>${testcontainers.version}</version>
    <scope>test</scope>
  </dependency>
</dependencies>

The store’s POM pulls Jedis in transitively, so no Jedis dependency is declared. The Boot managed version of Jedis would win without the jedis.version property.

application.yml: the store is plugged in through spring.quartz.properties
spring:
  quartz:
    job-store-type: memory            # irrelevant: jobStore.class below replaces the store
    scheduler-name: bookshelf
    properties:
      org.quartz.scheduler.instanceId: AUTO
      org.quartz.jobStore.class: net.joelinn.quartz.jobstore.RedisJobStore
      org.quartz.jobStore.host: ${REDIS_HOST:localhost}
      org.quartz.jobStore.port: ${REDIS_PORT:6379}
      org.quartz.jobStore.database: 0
      org.quartz.jobStore.keyPrefix: "bookshelf:"
      org.quartz.jobStore.lockTimeout: 30000
      org.quartz.jobStore.clusterCheckinInterval: 240000
      org.quartz.jobStore.misfireThreshold: 60000
      org.quartz.threadPool.threadCount: 3

Four details of this file were measured and matter. spring.quartz.job-store-type stays memory: Boot then does not create a DataSource-backed store, hands the properties to Spring’s SchedulerFactoryBean, and the org.quartz.jobStore.class property replaces the store (the same behaviour is described on Job Stores & Persistence; with jdbc instead the scheduler fails with DataSource name not set). Property values are strings, so the numbers are accepted by the store’s String overloads of setPort, setDatabase, setLockTimeout and setSsl. The scheduler instance id must be AUTO (see How it locks for why). And, because Quartz asks the store whether it is clustered, the log line and the SchedulerMetaData report clustered=true even for a single node, since RedisJobStore.isClustered() returns true unconditionally.

The jobs and their schedule are ordinary Spring beans, identical to the ones used on every other page of the section.

OverdueLoanRemindersJob: non-concurrent, persists its JobDataMap, counts its runs
package bookshelf;

import org.quartz.DisallowConcurrentExecution;
import org.quartz.JobExecutionContext;
import org.quartz.PersistJobDataAfterExecution;
import org.springframework.scheduling.quartz.QuartzJobBean;
import org.springframework.stereotype.Component;

@DisallowConcurrentExecution
@PersistJobDataAfterExecution
public class OverdueLoanRemindersJob extends QuartzJobBean {

    private final ReminderLog log;

    public OverdueLoanRemindersJob(ReminderLog log) {
        this.log = log;
    }

    @Override
    protected void executeInternal(JobExecutionContext context) {
        int runs = context.getJobDetail().getJobDataMap().getInt("runs") + 1;
        context.getJobDetail().getJobDataMap().put("runs", runs);
        log.record(context.getFireInstanceId(), runs);
    }
}
BookshelfSchedule: the JobDetail and its CronTrigger as beans
package bookshelf;

import org.quartz.CronScheduleBuilder;
import org.quartz.JobBuilder;
import org.quartz.JobDetail;
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;

@Configuration(proxyBeanMethods = false)
public class BookshelfSchedule {

    @Bean
    JobDetail overdueLoanRemindersJob() {
        return JobBuilder.newJob(OverdueLoanRemindersJob.class)
                .withIdentity("overdueLoanReminders", "bookshelf")
                .usingJobData("runs", 0)
                .storeDurably()
                .build();
    }

    @Bean
    Trigger overdueLoanRemindersTrigger(JobDetail overdueLoanRemindersJob,
                                        @Value("${bookshelf.reminders.cron:0 0 2 * * ?}") String cron) {
        return TriggerBuilder.newTrigger()
                .forJob(overdueLoanRemindersJob)
                .withIdentity("overdueLoanRemindersTrigger", "bookshelf")
                .withSchedule(CronScheduleBuilder.cronSchedule(cron)
                        .withMisfireHandlingInstructionFireAndProceed())
                .build();
    }
}

The test starts redis:8 as a plain GenericContainer (no database-specific module is used), points the store at it, waits until the job’s persisted counter shows three runs and then looks at what Quartz wrote with redis-cli inside the container. It ran green on the pinned versions.

RedisJobStoreTest: Testcontainers redis:8, the Bookshelf job runs and its state lives in Redis
package bookshelf;

import static org.assertj.core.api.Assertions.assertThat;

import java.time.Duration;
import java.util.List;

import org.junit.jupiter.api.Test;
import org.quartz.JobKey;
import org.quartz.Scheduler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Testcontainers
@SpringBootTest(properties = "bookshelf.reminders.cron=0/2 * * * * ?")
class RedisJobStoreTest {

    @Container
    static final GenericContainer<?> REDIS = new GenericContainer<>("redis:8").withExposedPorts(6379);

    @DynamicPropertySource
    static void redis(DynamicPropertyRegistry registry) {
        registry.add("REDIS_HOST", REDIS::getHost);
        registry.add("REDIS_PORT", () -> REDIS.getMappedPort(6379));
    }

    @Autowired Scheduler scheduler;
    @Autowired ReminderLog log;

    @Test
    void remindersFireAndStateLivesInRedis() throws Exception {
        JobKey reminders = JobKey.jobKey("overdueLoanReminders", "bookshelf");
        long deadline = System.nanoTime() + Duration.ofSeconds(20).toNanos();
        while (runs(reminders) < 3 && System.nanoTime() < deadline) {
            Thread.sleep(100);
        }

        // the scheduler really runs on the community store
        assertThat(scheduler.getMetaData().getJobStoreClass().getName())
                .isEqualTo("net.joelinn.quartz.jobstore.RedisJobStore");
        assertThat(log.entries()).hasSizeGreaterThanOrEqualTo(3);

        // @PersistJobDataAfterExecution: the counter is written back to Redis
        assertThat(runs(reminders)).isGreaterThanOrEqualTo(3);

        // what Quartz stored, as seen by redis-cli inside the container
        String keys = REDIS.execInContainer("redis-cli", "--scan", "--pattern", "bookshelf:*").getStdout();
        List<String> sorted = keys.lines().sorted().toList();
        assertThat(sorted).contains(
                "bookshelf:job:bookshelf:overdueLoanReminders",
                "bookshelf:trigger:bookshelf:overdueLoanRemindersTrigger",
                "bookshelf:triggers",
                "bookshelf:jobs");
    }

    private int runs(JobKey key) throws Exception {
        return scheduler.getJobDetail(key).getJobDataMap().getInt("runs");
    }
}

Running the same test with -Dquartz.version=2.5.2 or with -Djedis.version=7.4.1 produces the failures in the matrix above.

Reproducing the failing combinations with the same test
mvn test -Dtest=RedisJobStoreTest -Dquartz.version=2.5.2 -Djedis.version=3.3.0   # Jackson and AbstractMethodError
mvn test -Dtest=RedisJobStoreTest -Dquartz.version=2.4.1 -Djedis.version=7.4.1   # NoSuchMethodError on JedisCommands.del
mvn test -Dtest=RedisJobStoreTest -Dquartz.version=2.5.2 -Djedis.version=7.4.1   # the same error, what Boot 4.1.1 manages

Source: Maven: Configuring Maven (properties on the command line override the POM)

Configuration

Every org.quartz.jobStore.<name> property is set by Quartz by calling the setter set<Name> on the store, so the list of properties is the list of public setters of RedisJobStore in 1.2.0, which was read from the source. Names that look plausible but have no setter fail at startup: org.quartz.jobStore.isClustered gives NoSuchMethodException: No setter for property 'isClustered' (measured), because the store is always clustered and enabling it is done only through the scheduler’s instanceId.

Table 3. Properties of RedisJobStore 1.2.0 (all under org.quartz.jobStore)
Property Default Meaning

class

none

net.joelinn.quartz.jobstore.RedisJobStore

host

none, required

Redis host. For Redis Cluster and Sentinel a comma separated list of nodes, each host or host:port (a node without a port uses port).

port

6379

Port; used for nodes without an explicit port.

database

0

Logical database number, passed to JedisPool and JedisSentinelPool. Not used in Cluster mode, where only database 0 exists.

password

none

The AUTH password. There is no setter for a user name, so a Redis 6+ ACL user other than default cannot be used (see Redis security).

ssl

false

Boolean, honoured only by the standalone JedisPool. There is no way to pass a trust store or client certificate: TLS relies on the JVM’s default SSLContext (javax.net.ssl.trustStore and friends). The Cluster and Sentinel constructors do not receive the flag.

keyPrefix

empty

String prepended to every key. Leave it empty and the keys of Quartz sit next to everything else in the database. Use a prefix that ends with the delimiter, for example bookshelf:.

keyDelimiter

:

Separator between the parts of a key (job:<group>:<name>). Job, trigger and group names must not contain it: the key is parsed back by splitting on the delimiter and taking parts 1 and 2, so a job named loan:1001 is read back as loan.

lockTimeout

30000 ms

TTL of the global lock key (see below).

misfireThreshold

60000 ms

Same meaning as in Misfires. The store applies it itself; it does not inherit the JDBC default, it just happens to be the same number.

clusterCheckinInterval

240000 ms (4 minutes)

Time after which a scheduler that has not touched Redis is considered dead. The JDBC store default is 7500 ms; here it is thirty times longer, which matters for fail-over.

redisCluster

false

Use a JedisCluster built from the nodes in host. Uses a separate implementation, RedisClusterStorage.

redisSentinel

false

Use a JedisSentinelPool with the nodes in host as the sentinels.

masterGroupName

none

Sentinel master name (the first argument of JedisSentinelPool).

Two things cannot be set from properties. The connection timeout, socket timeout and retry fields (conTimeout 3000, soTimeout 3000, conRetries 5, used when a JedisCluster is created) are protected fields without a setter, and the trigger lock timeout of ten minutes is a constant. The Java API also has setJedisPool(Pool<Jedis>) and setJedisCluster(…​) to hand in an externally created pool, which is not reachable through spring.quartz.properties; a SchedulerFactoryBeanCustomizer that builds the store in code and passes it to setJobStore is the way to use them under Spring Boot.

Cluster and Sentinel, from the source (neither was started in the test environment, and the store does not start on the current Quartz anyway):

Sentinel and Cluster properties
# Redis Sentinel: the hosts are the sentinels, the password is the master's
org.quartz.jobStore.class=net.joelinn.quartz.jobstore.RedisJobStore
org.quartz.jobStore.redisSentinel=true
org.quartz.jobStore.masterGroupName=bookshelf-master
org.quartz.jobStore.host=sentinel-1.internal:26379,sentinel-2.internal:26379,sentinel-3.internal:26379
org.quartz.jobStore.password=${REDIS_PASSWORD}
org.quartz.jobStore.keyPrefix=bookshelf:

# Redis Cluster: the hosts are cluster nodes, database is ignored
org.quartz.jobStore.redisCluster=true
org.quartz.jobStore.host=redis-1.internal:6379,redis-2.internal:6379,redis-3.internal:6379
org.quartz.jobStore.password=${REDIS_PASSWORD}

Source: quartz-redis-jobstore README (property names verified against RedisJobStore.java 1.2.0)

In Cluster mode the global lock is still one key, so it lives in one hash slot on one node, and every operation of every scheduler goes through that node. For the topology itself, hash slots and MOVED redirections see Redis replication, Sentinel and Cluster.

What it stores

The store keeps everything as plain Redis data. After the job above had run three times, redis-cli inside the container showed these 14 keys (a run of the same application with three persisted runs; PTTL is in milliseconds, -1 means no expiry):

Keys written for one job with one cron trigger (redis-cli --scan with TYPE and PTTL)
bookshelf:acquired_triggers zset ttl=-1
bookshelf:job:bookshelf:overdueLoanReminders hash ttl=-1
bookshelf:job_data_map:bookshelf:overdueLoanReminders hash ttl=-1
bookshelf:job_group:bookshelf set ttl=-1
bookshelf:job_groups set ttl=-1
bookshelf:job_triggers:bookshelf:overdueLoanReminders set ttl=-1
bookshelf:jobs set ttl=-1
bookshelf:last_instance_active_time hash ttl=-1
bookshelf:last_triggers_release_time string ttl=-1
bookshelf:trigger:bookshelf:overdueLoanRemindersTrigger hash ttl=-1
bookshelf:trigger_group:bookshelf set ttl=-1
bookshelf:trigger_groups set ttl=-1
bookshelf:trigger_lock:bookshelf:overdueLoanRemindersTrigger string ttl=599916
bookshelf:triggers set ttl=-1

Source: Redis: TYPE and Redis: PTTL

Four columns of Redis keys written by quartz-redis-jobstore 1.2.0, all beginning with the configured key prefix. Definitions as hashes: job, job_data_map with every value stored as a string, trigger and calendar. Indexes as sets: jobs, job_groups, job_group, job_triggers, triggers, trigger_groups, trigger_group, calendars and blocked_jobs. Trigger state as sorted sets scored by next fire time: waiting_triggers, acquired_triggers, blocked_triggers, paused_triggers, paused_blocked_triggers, completed_triggers and error_triggers, a trigger being in exactly one of them. Locks and cluster state, the only keys with a time to live: the global lock key with a thirty second TTL, trigger_lock for non-concurrent jobs with value the scheduler instance id and a ten minute TTL, job_blocked, last_instance_active_time and last_triggers_release_time. A box at the bottom lists the consequences: no schema and no transactions, only the lock keys expire so an eviction policy other than noeviction can delete scheduling state, and the layout is not a stable contract

The hashes of the job and the trigger are readable:

The job and its data map as stored (redis-cli HGETALL)
> HGETALL bookshelf:job:bookshelf:overdueLoanReminders
group
bookshelf
name
overdueLoanReminders
jobClass
bookshelf.OverdueLoanRemindersJob
durable
true

> HGETALL bookshelf:job_data_map:bookshelf:overdueLoanReminders
runs
3

> HGETALL bookshelf:trigger:bookshelf:overdueLoanRemindersTrigger
trigger_class
org.quartz.impl.triggers.CronTriggerImpl
priority
5
group
bookshelf
cronExpression
0/2 * * * * ?
misfireInstruction
1
timeZone
Europe/Madrid
jobName
overdueLoanReminders
name
overdueLoanRemindersTrigger
startTime
1791193850000
jobGroup
bookshelf
nextFireTime
1791193856000
previousFireTime
1791193854022

Source: Redis: HGETALL

The state sorted sets use the next fire time in epoch milliseconds as the score (acquired_triggers held the trigger with score 1791193856000, equal to its nextFireTime), and a trigger is in exactly one of them. This is the Redis counterpart of the TRIGGER_STATE column of QRTZ_TRIGGERS in the JDBC schema, and finding the next triggers to fire is a ZRANGEBYSCORE on waiting_triggers.

JobDataMap values are stored as strings

JobDataMap is not serialized as a BLOB as it is in the JDBC store with useProperties=false. Each value is written with toString() and read back as a String. This was measured by scheduling a job with a Long, a Boolean and a record, then reading the stored JobDetail back:

What comes back from the store (measured, JobDataTypesTest)
dryRun -> String true
book -> String Book[isbn=978-0, tags=[a, b]]
memberId -> String 42

The typed getters of JobDataMap (getLong, getInt, getBoolean) parse a string value, so ids, counters and flags keep working (the persisted runs counter of the job above is read with getInt), and Spring’s QuartzJobBean setter binding converts strings as well. What does not survive is the type of anything else: get("memberId") returns a String, and a record, a list or any other object comes back as its toString() and cannot be turned into the object again. Put only simple values in the map (ids, flags, short strings) and read the object from the database in the job, which is the advice of Jobs & Job Data anyway.

How it locks: one global key, not row locks

Every operation of the store, reading a job, storing a trigger, acquiring the next triggers, marking a trigger fired, completing a job, runs inside the same wrapper (RedisJobStore.doWithLock). The wrapper takes one Redis key, <keyPrefix>lock, with the single-instance lock pattern, runs the operation and releases the key. This is the exact sequence captured by redis-cli MONITOR for one trigger firing (shortened):

A trigger firing as seen by MONITOR (one scheduler, redis:8)
"SET" "bookshelf:lock" "5caef733-43e7-4f7d-a424-4bfebff1c607" "nx" "px" "30000"
"EXISTS" "bookshelf:trigger:bookshelf:overdueLoanRemindersTrigger"
"ZSCORE" "bookshelf:acquired_triggers" "bookshelf:trigger:bookshelf:overdueLoanRemindersTrigger"
"HSET" "bookshelf:trigger:bookshelf:overdueLoanRemindersTrigger" "nextFireTime" "1791193860000"
"ZREM" "bookshelf:waiting_triggers" ...   (the trigger is removed from every state set)
"DEL" "bookshelf:trigger_lock:bookshelf:overdueLoanRemindersTrigger"
"ZADD" "bookshelf:waiting_triggers" "1.79119386E12" "bookshelf:trigger:bookshelf:overdueLoanRemindersTrigger"
"HGETALL" "bookshelf:job:bookshelf:overdueLoanReminders"
"ZADD" "bookshelf:blocked_triggers" "1.79119386E12" "bookshelf:trigger:bookshelf:overdueLoanRemindersTrigger"
"SET" "bookshelf:trigger_lock:bookshelf:overdueLoanRemindersTrigger" "MacBook-Air-2.local1791193850929" "nx" "px" "600000"
"SET" "bookshelf:job_blocked:bookshelf:overdueLoanReminders" "MacBook-Air-2.local1791193850929"
"SADD" "bookshelf:blocked_jobs" "bookshelf:job:bookshelf:overdueLoanReminders"
"GET" "bookshelf:lock"
"DEL" "bookshelf:lock"

Source: Redis: MONITOR

The mechanics, read from AbstractRedisStorage:

  • Acquire. SET <prefix>lock <random UUID> NX PX <lockTimeout>. If it answers OK the scheduler holds the lock; otherwise it sleeps a random 75 to 125 ms and tries again, forever.

  • Release. GET the key, and if the value equals the UUID this store instance generated, DEL it. Those are two commands, not an atomic compare-and-delete (the Lua script or DELEX pattern described in Redis use cases and patterns), and the UUID is a single field of the store shared by all threads of one scheduler.

  • Expiry. If an operation takes longer than lockTimeout (30 s), the key expires and another scheduler enters while the first is still working: there is no fencing token and no lock extension.

  • Scope. One key for the whole cluster, regardless of how many jobs, triggers or schedulers exist. All operations of all nodes are serialised on it.

  • Per-trigger lock. On top of that, for triggers of jobs annotated @DisallowConcurrentExecution only (the job of this page is one), trigger_lock:<group>:<name> is set with NX PX 600000 and the scheduler instance id as the value, to mark which scheduler is running the job. The ten minutes are a constant in the source (TRIGGER_LOCK_TIMEOUT), not a property. Triggers of ordinary jobs get no such key.

Redlock, the algorithm on the Redis documentation page about distributed locks, repeats this SET NX PX on several independent Redis masters and requires a majority. This store uses only the single-instance building block on one Redis, so it has none of Redlock’s resilience against the loss of a master, and with a replica that is promoted after a master failure the lock is exactly as safe as the replication is synchronous (it is not, see Durability). Redis use cases and patterns explains the pattern and its limits.

Table 4. How the Redis store differs from the JDBC store
Aspect JDBC (JobStoreTX, LocalDataSourceJobStore) RedisJobStore

Mutual exclusion

Row lock SELECT …​ FOR UPDATE on the TRIGGER_ACCESS and STATE_ACCESS rows of QRTZ_LOCKS, taken inside the transaction of the operation

One key <prefix>lock with SET NX PX, taken by a Jedis client outside any transaction

If the holder dies

The database releases the row lock when the connection or transaction ends

The key expires after lockTimeout (30 s by default)

Atomicity of an operation

The operation is a database transaction that commits or rolls back

Many separate commands with no MULTI; a crash in the middle leaves the keys half updated

Failed node detection

Check-ins every clusterCheckinInterval (7500 ms), recovery of other nodes' fired triggers (requestsRecovery) in seconds

Heartbeat written when a scheduler acquires triggers, clusterCheckinInterval 4 minutes, a clean-up sweep at most every 10 minutes, no requestsRecovery

sequenceDiagram participant S as Scheduler thread (node A) participant R as Redis participant W as Scheduler thread (node B) S->>R: SET prefix:lock uuid-A NX PX 30000 R-->>S: OK W->>R: SET prefix:lock uuid-B NX PX 30000 R-->>W: nil Note over W: sleeps 75 to 125 ms and retries S->>R: ZRANGEBYSCORE waiting_triggers 0 now+window S->>R: ZREM from every state set, ZADD acquired_triggers Note over S,R: for a non-concurrent job also SET trigger_lock:group:name nodeA NX PX 600000 S->>R: GET prefix:lock, then DEL if it is uuid-A W->>R: SET prefix:lock uuid-B NX PX 30000 R-->>W: OK W->>R: ZRANGEBYSCORE waiting_triggers 0 now+window Note over W: the acquired trigger is no longer waiting, so node B takes the next one

Durability

Redis is memory first, so what survives a restart is decided by the server’s persistence settings, not by Quartz. redis:8 ships with RDB snapshots enabled (save 3600 1 300 100 60 10000), appendonly no and appendfsync everysec, all read with CONFIG GET from the container used for this page. Quartz does not choose any of them.

The test below stores a job and a trigger through Quartz, shuts the scheduler down, kills or stops the Redis container and starts it again, and counts the keys with DBSIZE.

RedisDurabilityTest: how many keys survive a restart of Redis
package bookshelf;

import static org.assertj.core.api.Assertions.assertThat;

import java.time.Instant;
import java.util.Date;
import java.util.Properties;
import java.util.stream.Stream;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.quartz.JobBuilder;
import org.quartz.Scheduler;
import org.quartz.TriggerBuilder;
import org.quartz.impl.StdSchedulerFactory;
import org.testcontainers.containers.GenericContainer;

/**
 * What survives when the Redis process dies? Quartz stores a job and a trigger, the scheduler is shut
 * down, the Redis container is killed (SIGKILL) or stopped (SIGTERM) and started again, and the keys are counted.
 */
class RedisDurabilityTest {

    static Stream<Arguments> variants() {
        return Stream.of(
                // description, redis-server arguments, kill (true) or stop (false), keys expected after the restart
                Arguments.of("defaults, SIGKILL", new String[] {}, true, false),
                Arguments.of("defaults, SIGTERM (docker stop)", new String[] {}, false, true),
                Arguments.of("AOF everysec, SIGKILL", new String[] {"--appendonly", "yes"}, true, true),
                Arguments.of("AOF always, SIGKILL",
                        new String[] {"--appendonly", "yes", "--appendfsync", "always"}, true, true));
    }

    @ParameterizedTest(name = "{0}")
    @MethodSource("variants")
    void keysAfterRestart(String name, String[] extraArgs, boolean kill, boolean survives) throws Exception {
        String[] command = Stream.concat(Stream.of("redis-server"), Stream.of(extraArgs)).toArray(String[]::new);
        try (GenericContainer<?> redis = new GenericContainer<>("redis:8")
                .withExposedPorts(6379).withCommand(command)) {
            redis.start();

            Properties p = new Properties();
            p.setProperty("org.quartz.scheduler.instanceName", "durability");
            p.setProperty("org.quartz.scheduler.instanceId", "AUTO");
            p.setProperty("org.quartz.threadPool.threadCount", "2");
            p.setProperty("org.quartz.jobStore.class", "net.joelinn.quartz.jobstore.RedisJobStore");
            p.setProperty("org.quartz.jobStore.host", redis.getHost());
            p.setProperty("org.quartz.jobStore.port", String.valueOf(redis.getMappedPort(6379)));
            p.setProperty("org.quartz.jobStore.keyPrefix", "bookshelf:");
            Scheduler scheduler = new StdSchedulerFactory(p).getScheduler();
            scheduler.scheduleJob(
                    JobBuilder.newJob(OverdueLoanRemindersJob.class).withIdentity("member-42", "reminders")
                            .usingJobData("memberId", 42L).build(),
                    TriggerBuilder.newTrigger().withIdentity("member-42", "reminders")
                            .startAt(Date.from(Instant.now().plusSeconds(3600))).build());
            scheduler.shutdown();
            int before = dbSize(redis);
            assertThat(before).isPositive();

            if (kill) {
                redis.getDockerClient().killContainerCmd(redis.getContainerId()).exec();
            } else {
                redis.getDockerClient().stopContainerCmd(redis.getContainerId()).exec();
            }
            redis.getDockerClient().startContainerCmd(redis.getContainerId()).exec();

            int after = -1;
            for (int attempt = 0; attempt < 50 && after < 0; attempt++) {
                Thread.sleep(200);
                String reply = redis.execInContainer("redis-cli", "DBSIZE").getStdout().trim();
                after = reply.matches("\\d+") ? Integer.parseInt(reply) : -1;
            }
            System.out.println("RESULT " + name + ": DBSIZE before=" + before + " after=" + after);
            assertThat(after).isEqualTo(survives ? before : 0);
        }
    }

    private static int dbSize(GenericContainer<?> redis) throws Exception {
        return Integer.parseInt(redis.execInContainer("redis-cli", "DBSIZE").getStdout().trim());
    }
}
Table 5. Measured result (redis:8, 11 keys written)
Redis configuration Restart Keys before / after Meaning

Defaults (RDB save 3600 1 …​, no AOF)

SIGKILL then start

11 / 0

Every job and trigger is gone. The first snapshot was an hour away. Quartz starts empty, and only the jobs and triggers that your Spring beans re-register come back.

Defaults

SIGTERM (docker stop) then start

11 / 11

A graceful shutdown writes a final RDB snapshot.

--appendonly yes (fsync every second)

SIGKILL then start

11 / 11

The write was in the AOF. Killing the process does not lose what Redis already handed to the operating system.

--appendonly yes --appendfsync always

SIGKILL then start

11 / 11

Same, with every write synced before the reply.

The test kills the Redis process, not the machine. With appendfsync everysec a power loss or a kernel crash can still lose up to about a second of writes that were only in the operating system’s page cache; that case was not tested here, and it is described on Redis persistence and Redis as a primary database, whose section on the data-loss window of each profile applies unchanged to Quartz.

What to configure when Redis holds scheduling state:

  • Turn AOF on (appendonly yes), with appendfsync everysec as the usual compromise or always when losing a trigger is unacceptable and the latency is acceptable. Keep RDB as well for backups.

  • Never let Redis evict Quartz keys. redis:8 defaults to maxmemory-policy noeviction, but managed services often ship another one. Only the two lock key families carry a TTL, so volatile- policies would evict exactly the lock keys, and allkeys- policies would evict job and trigger hashes silently (see Redis architecture, keyspace and eviction).

  • Replication is asynchronous. After a Sentinel or Cluster fail-over, the promoted replica may lack the last writes of the failed master: a trigger created or rescheduled in the last moments can vanish, and a completed job can fire again. min-replicas-to-write narrows the window; WAIT and WAITAOF cannot be requested by the store. See Redis replication, Sentinel and Cluster.

  • Secure it. Anything that can write to the keyspace can create, delete or reschedule jobs, and a job is a class name that Quartz instantiates. Use requirepass and ssl as far as the store allows, bind it to private networks and see Redis security.

  • Reconcile after any loss. Jobs defined as Spring beans are re-registered at startup (the overwrite-existing-jobs behaviour of Job Stores & Persistence decides what happens to existing ones), but jobs created at runtime, such as the per-member reminders of Bookshelf, are gone: a scheduled reconciliation job that recreates them from the application database is the safety net.

Fail-over of a node

With JDBC a dead node is detected after the next check-in cycle, about eleven seconds measured on PostgreSQL with a two second check-in, and the jobs with requestsRecovery that it was running are fired again by another node. The Redis store behaves differently, and the difference was measured: the test below runs a non-concurrent job that takes 25 seconds on one scheduler, "crashes" the scheduler with shutdown(false) while the job is running (nothing is cleaned up in Redis), starts a second scheduler against the same Redis and measures how long it takes until the second scheduler runs the job.

RedisFailoverTest: takeover of a job that was running on a dead scheduler
package bookshelf;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.Date;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

import org.junit.jupiter.api.Test;
import org.quartz.DisallowConcurrentExecution;
import org.quartz.Job;
import org.quartz.JobBuilder;
import org.quartz.JobExecutionContext;
import org.quartz.Scheduler;
import org.quartz.SimpleScheduleBuilder;
import org.quartz.TriggerBuilder;
import org.quartz.impl.StdSchedulerFactory;
import org.testcontainers.containers.GenericContainer;

/** What happens to a running non-concurrent job when its scheduler dies, and a new one takes over? */
class RedisFailoverTest {

    static final List<String> RUNNERS = new CopyOnWriteArrayList<>();
    static volatile CountDownLatch firstRun = new CountDownLatch(1);

    @DisallowConcurrentExecution
    public static class WeeklyStatistics implements Job {
        @Override
        public void execute(JobExecutionContext context) throws org.quartz.JobExecutionException {
            try {
                RUNNERS.add(context.getScheduler().getSchedulerInstanceId());
                firstRun.countDown();
                Thread.sleep(25_000);                       // long running, never finishes before the "crash"
            } catch (Exception e) {
                throw new org.quartz.JobExecutionException(e);
            }
        }
    }

    private static Scheduler scheduler(GenericContainer<?> redis, long checkinMillis) throws Exception {
        Properties p = new Properties();
        p.setProperty("org.quartz.scheduler.instanceName", "failover");
        p.setProperty("org.quartz.scheduler.instanceId", "AUTO");
        p.setProperty("org.quartz.threadPool.threadCount", "2");
        p.setProperty("org.quartz.jobStore.class", "net.joelinn.quartz.jobstore.RedisJobStore");
        p.setProperty("org.quartz.jobStore.host", redis.getHost());
        p.setProperty("org.quartz.jobStore.port", String.valueOf(redis.getMappedPort(6379)));
        p.setProperty("org.quartz.jobStore.keyPrefix", "bookshelf:");
        if (checkinMillis > 0) {
            p.setProperty("org.quartz.jobStore.clusterCheckinInterval", String.valueOf(checkinMillis));
        }
        return new StdSchedulerFactory(p).getScheduler();
    }

    private long secondsUntilTakeover(long checkinMillis, long pauseBeforeSecondNodeMillis) throws Exception {
        try (GenericContainer<?> redis = new GenericContainer<>("redis:8").withExposedPorts(6379)) {
            redis.start();
            RUNNERS.clear();
            firstRun = new CountDownLatch(1);

            Scheduler first = scheduler(redis, checkinMillis);
            first.scheduleJob(JobBuilder.newJob(WeeklyStatistics.class).withIdentity("weeklyStatistics", "bookshelf").build(),
                    TriggerBuilder.newTrigger().withIdentity("weeklyStatistics", "bookshelf").startAt(new Date())
                            .withSchedule(SimpleScheduleBuilder.repeatSecondlyForever(2)).build());
            first.start();
            assertThat(firstRun.await(20, TimeUnit.SECONDS)).isTrue();
            first.shutdown(false);                          // "crash": the job is still running, nothing is cleaned up
            Thread.sleep(pauseBeforeSecondNodeMillis);

            Scheduler second = scheduler(redis, checkinMillis);
            second.start();
            long started = System.nanoTime();
            try {
                while (System.nanoTime() - started < TimeUnit.SECONDS.toNanos(60)) {
                    if (RUNNERS.size() > 1) {
                        return TimeUnit.NANOSECONDS.toSeconds(System.nanoTime() - started);
                    }
                    Thread.sleep(200);
                }
                return -1;                                  // had not taken over after 60 s
            } finally {
                second.shutdown(false);
            }
        }
    }

    @Test
    void takeoverTimes() throws Exception {
        long fast = secondsUntilTakeover(2_000, 3_000);
        long fastButImmediate = secondsUntilTakeover(2_000, 0);
        long defaults = secondsUntilTakeover(0, 3_000);
        System.out.println("RESULT clusterCheckinInterval=2000, second node 3 s later: " + fast + " s");
        System.out.println("RESULT clusterCheckinInterval=2000, second node at once: " + fastButImmediate + " s");
        System.out.println("RESULT default clusterCheckinInterval (240000), second node 3 s later: " + defaults + " s");
    }
}
Table 6. Measured takeover times (Quartz 2.4.1, Jedis 3.3.0, redis:8; two runs gave the same pattern)
Configuration Second node starts Time until it ran the job

clusterCheckinInterval=2000

3 s after the crash

0 s (immediately at its first trigger scan)

clusterCheckinInterval=2000

immediately

28 s and 23 s in the two runs, about the 25 s the orphaned job still needed to finish

default (240000)

3 s after the crash

not within the 60 s the test waits

These numbers match what the source of AbstractRedisStorage does. A scheduler writes its heartbeat (last_instance_active_time) only when it acquires triggers. Orphaned ACQUIRED and BLOCKED triggers are released by a sweep that runs when the scheduler’s own heartbeat is missing (a freshly started node, hence "immediately at its first scan") or when ten minutes have passed since the last sweep of any node. A trigger that carries a trigger_lock, which means a trigger of a @DisallowConcurrentExecution job such as this one, is only released when the instance named in the lock has not checked in for clusterCheckinInterval; a trigger without one is released by the next sweep. With the default of four minutes, a restarted application therefore waits minutes, not seconds, for its non-concurrent jobs, and a cluster whose nodes are not restarted can wait for the next ten-minute sweep (that last part was read from the source, not run, because it needs minutes of waiting). The same mechanism is a plausible reason why the restart check of the compatibility matrix failed in one run, in which the application was restarted immediately after it had been stopped; that explanation was not verified.

There is no equivalent of requestsRecovery: the job running on the dead node is never re-fired, it simply stays blocked until the sweep releases its trigger, and the next scheduled fire time is the one that runs. For the weekly statistics report of Bookshelf, which must be recovered after a crash, that is the wrong behaviour, and the JDBC clustering of Clustering is the right tool.

Option: Quartz on SQL, Redis for the data

When the application already uses Redis for its data, the dependable arrangement is the same as the first option of the MongoDB page: Quartz keeps its tables in a small SQL database (PostgreSQL here, see Quartz with PostgreSQL) and the business data stays in Redis, accessed with Spring Data Redis (Spring Boot, Spring Data Redis and custom queries). Quartz gets everything the community store lacks: transactions, requestsRecovery, fail-over in seconds, and the Quartz 2.5 and Spring Boot 4.1 support.

The price is that Redis and PostgreSQL do not share a transaction: a reminder can be scheduled for a loan that Redis lost, or a loan can exist without its reminder. The Bookshelf example makes the job idempotent and lets it verify the loan, the same defence recommended on Jobs & Job Data.

application.yml: Quartz on PostgreSQL, data in Redis
spring:
  datasource:
    url: jdbc:postgresql://${DB_HOST:localhost}:5432/bookshelf
    username: ${DB_USER:bookshelf}
    password: ${DB_PASSWORD}
  data:
    redis:
      host: ${REDIS_HOST:localhost}
      port: 6379
  flyway:
    enabled: true            # creates the QRTZ_ tables from db/migration/V1__quartz_tables.sql
  quartz:
    job-store-type: jdbc
    jdbc:
      initialize-schema: never
    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"
pom.xml: the dependencies of the alternative (Boot 4.1.1 manages every version except ShedLock’s)
<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-redis</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>
  <scope>runtime</scope>
</dependency>

Spring Boot 3.5 has no spring-boot-starter-flyway: declare org.flywaydb:flyway-core and flyway-database-postgresql directly (or org.liquibase:liquibase-core).

Creating the Quartz tables

spring.quartz.jdbc.initialize-schema is never and the schema is owned by a migration tool, for the reasons given on Job Stores & Persistence (always drops and recreates the tables on every start). With Flyway the migration is the Quartz script for the database, tables_postgres.sql from org/quartz/impl/jdbcjobstore/ inside the Quartz jar, copied to src/main/resources/db/migration/V1__quartz_tables.sql without its leading DROP TABLE IF EXISTS lines (the script starts by dropping the eleven tables; harmless on a new schema, but a migration should never drop). Flyway logged Migrating schema "public" to version "1 - quartz tables" and Successfully applied 1 migration, and Boot starts the scheduler after the migration.

V1__quartz_tables.sql: the first lines of the Flyway migration (the rest is the unchanged Quartz script, 11 tables)
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)
);

For Liquibase, Quartz itself ships a changelog, org/quartz/impl/jdbcjobstore/liquibase.quartz.init.xml in the Quartz jar (a single change set with id quartz-init, author quartz, a BYTEA blob type on PostgreSQL). It is included from the application’s master changelog, with spring-boot-starter-liquibase instead of the Flyway dependencies and spring.liquibase.change-log pointing at it. The run below used exactly this file and the same test.

db/changelog/db.changelog-master.yaml: including the changelog bundled in the Quartz jar
databaseChangeLog:
  - include:
      file: org/quartz/impl/jdbcjobstore/liquibase.quartz.init.xml
application.yml fragment for the Liquibase variant
spring:
  liquibase:
    change-log: classpath:db/changelog/db.changelog-master.yaml

Choosing between the tools is covered in Choosing a migration tool and Liquibase: getting started.

The job, the service and the test

A loan is written to Redis as a hash and a one-shot trigger is scheduled for the reminder, with the keys used throughout the section (job and trigger loan-<loanId> in the group reminders). The job reads the loan back from Redis and sends the reminder only if the loan is still open and SET reminder-sent:<loanId> 1 NX EX says nobody has sent it yet.

MemberReminderJob: reads the loan from Redis and sends at most once
package bookshelf;

import java.time.Duration;

import org.quartz.JobExecutionContext;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.quartz.QuartzJobBean;

/** Reads the loan from Redis and sends the reminder at most once, however often Quartz runs it. */
public class MemberReminderJob extends QuartzJobBean {

    private final StringRedisTemplate redis;
    private final ReminderSender sender;

    public MemberReminderJob(StringRedisTemplate redis, ReminderSender sender) {
        this.redis = redis;
        this.sender = sender;
    }

    @Override
    protected void executeInternal(JobExecutionContext context) {
        String loanId = context.getMergedJobDataMap().getString("loanId");
        String status = (String) redis.opsForHash().get("loan:" + loanId, "status");
        if (!"OPEN".equals(status)) {
            return;                                   // returned (or deleted) in the meantime
        }
        Boolean first = redis.opsForValue()
                .setIfAbsent("reminder-sent:" + loanId, "1", Duration.ofDays(30));
        if (Boolean.TRUE.equals(first)) {             // idempotency guard: SET key value NX EX ...
            sender.send(loanId, context.getMergedJobDataMap().getString("memberId"));
        }
    }
}
LoanService: Redis for the loan, Quartz (PostgreSQL) for the reminder
package bookshelf;

import java.time.Instant;
import java.util.Date;
import java.util.Map;

import org.quartz.JobBuilder;
import org.quartz.JobDataMap;
import org.quartz.JobKey;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.TriggerBuilder;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

@Service
public class LoanService {

    private final StringRedisTemplate redis;
    private final Scheduler scheduler;

    public LoanService(StringRedisTemplate redis, Scheduler scheduler) {
        this.redis = redis;
        this.scheduler = scheduler;
    }

    /** Business data goes to Redis, the reminder goes to Quartz (PostgreSQL). No shared transaction. */
    public void lend(String loanId, String memberId, String isbn, Instant remindAt) throws SchedulerException {
        redis.opsForHash().putAll("loan:" + loanId,
                Map.of("memberId", memberId, "isbn", isbn, "status", "OPEN"));

        JobKey key = JobKey.jobKey("loan-" + loanId, "reminders");
        scheduler.scheduleJob(
                JobBuilder.newJob(MemberReminderJob.class).withIdentity(key)
                        .usingJobData(new JobDataMap(Map.of("loanId", loanId, "memberId", memberId)))
                        .build(),
                TriggerBuilder.newTrigger().withIdentity("loan-" + loanId, "reminders")
                        .startAt(Date.from(remindAt)).build());
    }

    public void giveBack(String loanId) throws SchedulerException {
        redis.opsForHash().put("loan:" + loanId, "status", "RETURNED");
        scheduler.deleteJob(JobKey.jobKey("loan-" + loanId, "reminders"));
    }
}
QuartzOnSqlRedisDataTest: PostgreSQL 17 and Redis 8 with Testcontainers
package bookshelf;

import static org.assertj.core.api.Assertions.assertThat;

import java.time.Duration;
import java.time.Instant;

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.data.redis.core.StringRedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;

@Testcontainers
@SpringBootTest
class QuartzOnSqlRedisDataTest {

    @Container
    static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:17");

    @Container
    static final GenericContainer<?> REDIS = new GenericContainer<>("redis:8").withExposedPorts(6379);

    @DynamicPropertySource
    static void containers(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
        registry.add("spring.datasource.username", POSTGRES::getUsername);
        registry.add("spring.datasource.password", POSTGRES::getPassword);
        registry.add("spring.data.redis.host", REDIS::getHost);
        registry.add("spring.data.redis.port", () -> REDIS.getMappedPort(6379));
    }

    @Autowired LoanService loans;
    @Autowired ReminderSender sender;
    @Autowired StringRedisTemplate redis;
    @Autowired JdbcTemplate jdbc;
    @Autowired Scheduler scheduler;

    @Test
    void reminderIsStoredInPostgresAndSentOnceFromRedisData() throws Exception {
        loans.lend("1001", "42", "978-0-13-468599-1", Instant.now().plusSeconds(3));

        // the trigger lives in the QRTZ_ tables, the loan lives in Redis
        assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM qrtz_triggers WHERE trigger_name = 'loan-1001'",
                Integer.class)).isEqualTo(1);
        assertThat(redis.opsForHash().get("loan:1001", "status")).isEqualTo("OPEN");

        long deadline = System.nanoTime() + Duration.ofSeconds(20).toNanos();
        while (sender.sent().isEmpty() && System.nanoTime() < deadline) {
            Thread.sleep(100);
        }
        assertThat(sender.sent()).containsExactly("1001:42");
        assertThat(redis.hasKey("reminder-sent:1001")).isTrue();
    }

    @Test
    void returningTheBookCancelsTheReminder() throws Exception {
        loans.lend("1002", "43", "978-0-13-468599-1", Instant.now().plusSeconds(3600));
        loans.giveBack("1002");

        assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM qrtz_triggers WHERE trigger_name = 'loan-1002'",
                Integer.class)).isZero();
        assertThat(redis.opsForHash().get("loan:1002", "status")).isEqualTo("RETURNED");
        assertThat(scheduler.getSchedulerName()).isEqualTo("quartzScheduler");
    }
}

Both tests passed, with Flyway and with Liquibase. The first proves the split: the trigger is a row in qrtz_triggers in PostgreSQL, the loan is a hash in Redis, the job fires from the Redis data and the reminder is sent once. The second proves that returning the book deletes the Quartz job from PostgreSQL and marks the loan in Redis.

A dedicated database for Quartz

The configuration above uses the application’s only DataSource. As soon as the application has a second one (a primary business database next to the Quartz one), Boot’s JDBC store needs the business one marked @Primary and the Quartz one @QuartzDataSource; with no @Primary (even if one is @QuartzDataSource) it silently falls back to the RAM store (measured on the PostgreSQL work of this section: jobs lost on restart and no error). Flyway then needs @FlywayDataSource on the same bean to migrate the right database. This was run with two PostgreSQL containers.

QuartzDatabaseConfig: a business DataSource and a dedicated Quartz DataSource
package dedicated;

import javax.sql.DataSource;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.flyway.autoconfigure.FlywayDataSource;
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 org.springframework.context.annotation.Primary;

@Configuration(proxyBeanMethods = false)
class QuartzDatabaseConfig {

    // the application's own database: whatever the business code uses (JPA, JdbcTemplate, ...)
    @Bean
    @ConfigurationProperties("spring.datasource")
    DataSourceProperties businessDataSourceProperties() {
        return new DataSourceProperties();
    }

    @Bean
    @Primary
    DataSource businessDataSource(DataSourceProperties businessDataSourceProperties) {
        return businessDataSourceProperties.initializeDataSourceBuilder().build();
    }

    // a small dedicated database for the QRTZ_ tables, used by Quartz and by Flyway only
    @Bean
    @ConfigurationProperties("quartz.datasource")
    DataSourceProperties quartzDataSourceProperties() {
        return new DataSourceProperties();
    }

    @Bean
    @QuartzDataSource
    @FlywayDataSource
    DataSource quartzDataSource(DataSourceProperties quartzDataSourceProperties) {
        return quartzDataSourceProperties.initializeDataSourceBuilder().build();
    }
}

The DataSourceProperties of the business database must not be @Primary: with two properties beans of the same type, @Primary wins over matching the parameter name, the Quartz DataSource is built from the business properties and both point at the same database. That mistake was made and measured while preparing this page: the QRTZ tables showed up in the business database. The test that guards the correct wiring follows.

DedicatedQuartzDatabaseTest: the QRTZ tables live in the Quartz database only
package dedicated;

import static org.assertj.core.api.Assertions.assertThat;

import javax.sql.DataSource;

import org.junit.jupiter.api.Test;
import org.quartz.Scheduler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.test.context.SpringBootTest;
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.postgresql.PostgreSQLContainer;

@Testcontainers
@SpringBootTest(classes = DedicatedApplication.class)
class DedicatedQuartzDatabaseTest {

    @Container
    static final PostgreSQLContainer BUSINESS = new PostgreSQLContainer("postgres:17");

    @Container
    static final PostgreSQLContainer QUARTZ = new PostgreSQLContainer("postgres:17");

    @DynamicPropertySource
    static void databases(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", BUSINESS::getJdbcUrl);
        registry.add("spring.datasource.username", BUSINESS::getUsername);
        registry.add("spring.datasource.password", BUSINESS::getPassword);
        registry.add("quartz.datasource.url", QUARTZ::getJdbcUrl);
        registry.add("quartz.datasource.username", QUARTZ::getUsername);
        registry.add("quartz.datasource.password", QUARTZ::getPassword);
    }

    @Autowired Scheduler scheduler;
    @Autowired @Qualifier("businessDataSource") DataSource business;
    @Autowired @Qualifier("quartzDataSource") DataSource quartz;

    @Test
    void quartzTablesLiveInTheDedicatedDatabaseOnly() throws Exception {
        long deadline = System.currentTimeMillis() + 15_000;
        while (PingJob.RUNS.get() < 2 && System.currentTimeMillis() < deadline) {
            Thread.sleep(100);
        }
        assertThat(PingJob.RUNS.get()).isGreaterThanOrEqualTo(2);

        String sql = "SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'qrtz_triggers'";
        assertThat(new JdbcTemplate(quartz).queryForObject(sql, Integer.class)).isEqualTo(1);
        assertThat(new JdbcTemplate(business).queryForObject(sql, Integer.class)).isZero();
        assertThat(scheduler.getMetaData().getJobStoreClass().getName())
                .isEqualTo("org.springframework.scheduling.quartz.LocalDataSourceJobStore");
    }
}

In Spring Boot 3.5 QuartzDataSource is org.springframework.boot.autoconfigure.quartz.QuartzDataSource, FlywayDataSource is under org.springframework.boot.autoconfigure.flyway, and DataSourceProperties under org.springframework.boot.autoconfigure.jdbc; Boot 4.1 moved them to org.springframework.boot.quartz.autoconfigure, org.springframework.boot.flyway.autoconfigure and org.springframework.boot.jdbc.autoconfigure.

Option: no Quartz, ShedLock with Redis

If the job does not need persistence across restarts, misfire policies or runtime scheduling (the cache refresh of Bookshelf is the textbook example), @Scheduled with ShedLock is enough, and Redis is a good lock store because the application already has a Redis. The lock provider is net.javacrumbs.shedlock:shedlock-provider-redis-spring:7.10.1 (the latest release on Maven Central on 5 October 2026, compiled against Spring Data Redis 4.1.1), together with net.javacrumbs.shedlock:shedlock-spring:7.10.1 for @EnableSchedulerLock and @SchedulerLock. The class is net.javacrumbs.shedlock.provider.redis.spring.RedisLockProvider. Its constructors take a RedisConnectionFactory (or a StringRedisTemplate), an optional environment name (default default) and an optional key prefix (default job-lock), and a RedisLockProvider.Builder adds a safeUpdate flag.

Its mechanics, from the 7.10.1 sources: the key is <keyPrefix>:<environment>:<lockName>, for example job-lock:bookshelf:catalogueRefresh; locking is SET key value NX PX <lockAtMostFor>, the value is ADDED:<timestamp>@<host>:<uuid>; unlocking deletes the key, or, when lockAtLeastFor has not elapsed yet, shortens its TTL to the remainder. With safeUpdate(true) the delete and the TTL update run as Lua scripts that act only if the value is still the caller’s, which stops a node whose lock had expired from releasing someone else’s. This is the single-instance lock of the previous sections, with a better release, used to skip a duplicate run rather than to guard data.

ShedLockRedisTest: two instances of the same application, one execution per tick
package lockdemo;

import static org.assertj.core.api.Assertions.assertThat;

import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CopyOnWriteArrayList;

import net.javacrumbs.shedlock.core.LockConfiguration;
import net.javacrumbs.shedlock.core.LockProvider;
import net.javacrumbs.shedlock.core.SimpleLock;
import net.javacrumbs.shedlock.provider.redis.spring.RedisLockProvider;
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;

import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Testcontainers
class ShedLockRedisTest {

    /** Every execution of the scheduled method, from both "instances". */
    static final List<Long> EXECUTIONS = new CopyOnWriteArrayList<>();

    @Container
    static final GenericContainer<?> REDIS = new GenericContainer<>("redis:8").withExposedPorts(6379);

    @Configuration
    @EnableScheduling
    @EnableSchedulerLock(defaultLockAtMostFor = "PT30S")
    static class InstanceConfig {

        @Bean(destroyMethod = "destroy")
        LettuceConnectionFactory connectionFactory() {
            return new LettuceConnectionFactory(
                    new RedisStandaloneConfiguration(REDIS.getHost(), REDIS.getMappedPort(6379)));
        }

        @Bean
        LockProvider lockProvider(RedisConnectionFactory connectionFactory) {
            return new RedisLockProvider(connectionFactory, "bookshelf");
        }

        @Bean
        CatalogueRefresh catalogueRefresh() {
            return new CatalogueRefresh();
        }
    }

    static class CatalogueRefresh {

        @Scheduled(fixedRate = 1000)
        @SchedulerLock(name = "catalogueRefresh", lockAtMostFor = "PT10S", lockAtLeastFor = "PT0.9S")
        public void refresh() {
            EXECUTIONS.add(System.currentTimeMillis());
        }
    }

    private static AnnotationConfigApplicationContext startInstance() {
        AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
        context.register(InstanceConfig.class);
        context.refresh();
        return context;
    }

    @Test
    void twoInstancesRunTheJobOncePerTick() throws Exception {
        try (AnnotationConfigApplicationContext first = startInstance();
             AnnotationConfigApplicationContext second = startInstance()) {
            Thread.sleep(6500);

            List<Long> times = EXECUTIONS.stream().sorted().toList();
            long spanSeconds = (times.get(times.size() - 1) - times.get(0)) / 1000;
            System.out.println("RESULT executions=" + times.size() + " spanSeconds=" + spanSeconds);
            // two instances tick every second, yet the job ran about once per second in total, not twice
            assertThat(times.size()).isBetween(3, (int) spanSeconds + 2);

            // the lock lives in one Redis key, present between ticks because of lockAtLeastFor
            String key = "";
            for (int attempt = 0; attempt < 40 && key.isEmpty(); attempt++) {
                key = REDIS.execInContainer("redis-cli", "--scan", "--pattern", "job-lock:*").getStdout().trim();
                Thread.sleep(50);
            }
            assertThat(key).isEqualTo("job-lock:bookshelf:catalogueRefresh");
        }
    }

    @Test
    void lockProviderHandsOutTheLockOnce() throws Exception {
        LettuceConnectionFactory factory = new LettuceConnectionFactory(
                new RedisStandaloneConfiguration(REDIS.getHost(), REDIS.getMappedPort(6379)));
        factory.afterPropertiesSet();
        try {
            LockProvider provider = new RedisLockProvider.Builder(factory)
                    .environment("bookshelf").keyPrefix("job-lock").safeUpdate(true).build();
            LockConfiguration configuration = new LockConfiguration(
                    Instant.now(), "weeklyStatistics", Duration.ofSeconds(30), Duration.ZERO);

            Optional<SimpleLock> first = provider.lock(configuration);
            Optional<SimpleLock> second = provider.lock(configuration);
            assertThat(first).isPresent();
            assertThat(second).isEmpty();                    // SET key value NX PX 30000 returned nil

            first.get().unlock();                            // Lua script: delete only if the value is still ours
            assertThat(provider.lock(configuration)).isPresent();
        } finally {
            factory.destroy();
        }
    }
}

Both tests passed on redis:8. In three consecutive runs of the first one the two instances, ticking every second for about six seconds, ran the method 7 times in a 5 second span, where two unlocked instances would have run it about twice per second; in these runs one of the instances won the lock almost every tick (the two instances start together in one JVM, so the result says nothing about fairness). The second test shows the contract directly: the second lock call for the same name returns an empty Optional, and after unlock the lock is available again.

In a Spring Boot application the provider is one bean built from the auto-configured connection factory:

A Spring Boot LockProvider bean for the auto-configured Redis connection
@Bean
LockProvider lockProvider(RedisConnectionFactory connectionFactory) {
    return new RedisLockProvider(connectionFactory, "bookshelf");
}

The comparison with the JDBC, MongoDB and other providers and the lockAtMostFor and lockAtLeastFor guidance are on Scheduling & ShedLock; the Redis lock primitive is explained in Redis use cases and patterns.

Other NoSQL stores

The same conclusion applies to the other NoSQL databases documented on this site. Quartz 2.5.2 ships a RAM store and the JDBC stores, and none of the databases below was tested with a third-party Quartz store for this section, so the dependable pattern is the same as for MongoDB and Redis: Quartz on a small SQL database, the application data wherever it belongs, idempotent jobs and a reconciliation job to cover the missing shared transaction. That is the first option of Quartz with MongoDB. The data side is documented in Couchbase (Spring Data in Spring Data Couchbase), Neo4j (Spring Data Neo4j) and Elasticsearch. A job that only needs to run once across several instances, with no persistence of its own, can use a ShedLock provider for the store it already has, as in the previous section.

flowchart TD A["Redis in the application, jobs need to be scheduled"] --> B{"Must triggers survive restarts, misfires, runtime scheduling?"} B -->|no| C["@Scheduled plus ShedLock RedisLockProvider"] B -->|yes| D{"Can Quartz keep its tables in a SQL database?"} D -->|yes| E["Quartz on PostgreSQL, data in Redis: the recommended option"] D -->|no| F{"Can you pin Quartz 2.4.1, Jedis 3.3.0 and own the store?"} F -->|yes| G["quartz-redis-jobstore 1.2.0 with AOF, noeviction and a reconciliation job"] F -->|no| H["Write or buy a JobStore for Quartz 2.5, or use another scheduler"]

Pitfalls

Table 7. Pitfalls of Quartz with Redis
Pitfall What happens What to do

Using quartz-redis-jobstore with Boot’s managed versions

Startup fails with NoSuchMethodError on JedisCommands.del(String) (Jedis 7.4.1) or, with Jedis 3.3.0 on Quartz 2.5.2, a Jackson UnrecognizedPropertyException and an AbstractMethodError in the scheduler thread

Do not use it on Quartz 2.5. If you must, pin quartz.version 2.4.1 and jedis.version 3.3.0 and test every upgrade

Setting org.quartz.jobStore.isClustered

NoSuchMethodException: No setter for property 'isClustered'

The store is always clustered; set org.quartz.scheduler.instanceId to AUTO

Leaving the instance id at its default

The default id is NON_CLUSTERED and it is the value written into the trigger_lock keys and the heartbeat hash, so two nodes cannot tell their triggers apart

org.quartz.scheduler.instanceId=AUTO on every node

job-store-type: jdbc together with jobStore.class

DataSource name not set

Keep job-store-type at memory when the store comes from spring.quartz.properties

Values in the JobDataMap that are not strings

They come back as strings (42 as "42", a record as its toString())

Only store ids and flags, read the data from the database in the job

: in job, trigger or group names

Keys are parsed by splitting on the delimiter, so loan:1001 becomes loan

Use - or _ in names (the section’s loan-<id> convention) or change keyDelimiter

Empty keyPrefix, shared database

Quartz keys mix with application keys in database 0

Always set a prefix such as bookshelf: and a dedicated database or instance

Default Redis persistence

After a crash of Redis the keys are gone (11 keys before, 0 after a SIGKILL)

appendonly yes, replicas, min-replicas-to-write, backups

Eviction policy other than noeviction

Job or trigger hashes can be evicted silently, or only the lock keys with volatile-*

maxmemory-policy noeviction on the Redis that holds Quartz

Expecting requestsRecovery

The store never re-fires a job interrupted by a crash

Use JDBC clustering for jobs that must recover, or make the job restartable by design

Waiting for fail-over with the default clusterCheckinInterval

A restarted node did not take over a blocked trigger within 60 s; the sweep is gated by 4 minutes and 10 minutes

Lower clusterCheckinInterval, restart nodes only after it has elapsed, and measure it in your environment

lockTimeout shorter than the slowest store operation

The global lock expires in the middle of an operation and another node enters

Keep the default 30 s and keep Redis latency low

A Redis ACL user or TLS client certificates

Only a password and a TLS boolean exist

Protect the default user with requirepass, or do not use the store

@Primary on the business DataSourceProperties bean

The Quartz DataSource is built from the business properties and both point at the same database

Mark only the DataSource as @Primary, give each properties bean its own name

Running the Quartz script as a migration unchanged

DROP TABLE IF EXISTS lines in tables_postgres.sql are harmless on a new schema and a hazard in a migration that is ever replayed

Remove them from the Flyway migration or use the bundled Liquibase changelog

ShedLock with a short lockAtMostFor

If the job runs longer, the key expires and a second instance runs it concurrently

Choose lockAtMostFor above the longest run, see Scheduling & ShedLock

What changed

  • Quartz 2.4 to 2.5. JobDetail.isConcurrentExectionDisallowed() (the misspelled method) was removed and isConcurrentExecutionDisallowed() is the only one, and JobStore gained the abstract method getAcquireRetryDelay(int). Both break stores compiled for earlier versions, including this one and quartz-mongodb. Terracotta was removed in 2.4.0, so JDBC remains the only officially supported store for clusters, as collected on Migration & What’s Changed.

  • Jedis. The store’s 3.3.0 is the last version it works with. Spring Boot 3.5.16 manages Jedis 6.0.0 and Spring Boot 4.1.1 manages 7.4.1, and every major in between that was tried fails identically.

  • Spring Boot 3.5 to 4.1. QuartzDataSource, QuartzTransactionManager and SchedulerFactoryBeanCustomizer moved to org.springframework.boot.quartz.autoconfigure, the Flyway and Liquibase starters were added, and DataSourceProperties moved to org.springframework.boot.jdbc.autoconfigure. The spring.quartz.* property names did not change. Both lines manage Quartz 2.5.2, so the community Redis store fails on both; it was only run on 4.1.1.

  • ShedLock. 7.10.1 is the current release of the Redis provider on Maven Central; it has the Builder with the safeUpdate option used above and is built against Spring Data Redis 4.1.1.