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:
| Question | Answer |
|---|---|
Coordinates |
|
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 |
Official? |
No. The Quartz 2.5.2 jar contains |
Features |
Persistence, a cluster mode (always on), misfire handling, calendars (a Jackson mixin exists for |
Not implemented |
|
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.
| 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. |
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 |
Matrix run and clean re-run |
2.4.1 |
7.4.1 |
Fails at startup, same |
Matrix run and clean re-run |
2.4.1 |
4.4.8, 5.2.0 and 6.2.0 |
Fails at startup, the same |
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.JedisCommandsof Jedis 3.3.0 and callsdel(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 theNoSuchMethodError. Boot’s dependency management pins Jedis, and the store’s POM3.3.0loses against it. -
Jackson mixin.
JobDetailMixinignoresisConcurrentExectionDisallowed()(the misspelling was in Quartz up to 2.4). Quartz 2.5 removed that method and addedisConcurrentExecutionDisallowed(), so the property is now serialized asconcurrentExecutionDisallowedand cannot be read back intoJobDetailImpl. -
The
JobStoreSPI. Quartz 2.5.2 addedlong getAcquireRetryDelay(int failureCount)toorg.quartz.spi.JobStoreas an abstract method. A store compiled earlier does not implement it, which is theAbstractMethodError. 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.
<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.
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.
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);
}
}
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();
}
}
Source: Spring Boot: Quartz Scheduler
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.
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.
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.
| Property | Default | Meaning |
|---|---|---|
|
none |
|
|
none, required |
Redis host. For Redis Cluster and Sentinel a comma separated list of nodes, each |
|
|
Port; used for nodes without an explicit port. |
|
|
Logical database number, passed to |
|
none |
The |
|
|
Boolean, honoured only by the standalone |
|
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 |
|
|
Separator between the parts of a key ( |
|
|
TTL of the global lock key (see below). |
|
|
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. |
|
|
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. |
|
|
Use a |
|
|
Use a |
|
none |
Sentinel master name (the first argument of |
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):
# 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):
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
The hashes of the job and the trigger are readable:
> 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:
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):
"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 answersOKthe scheduler holds the lock; otherwise it sleeps a random 75 to 125 ms and tries again, forever. -
Release.
GETthe key, and if the value equals the UUID this store instance generated,DELit. Those are two commands, not an atomic compare-and-delete (the Lua script orDELEXpattern 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
@DisallowConcurrentExecutiononly (the job of this page is one),trigger_lock:<group>:<name>is set withNX PX 600000and 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.
| Aspect | JDBC (JobStoreTX, LocalDataSourceJobStore) |
RedisJobStore |
|---|---|---|
Mutual exclusion |
Row lock |
One key |
If the holder dies |
The database releases the row lock when the connection or transaction ends |
The key expires after |
Atomicity of an operation |
The operation is a database transaction that commits or rolls back |
Many separate commands with no |
Failed node detection |
Check-ins every |
Heartbeat written when a scheduler acquires triggers, |
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.
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());
}
}
| Redis configuration | Restart | Keys before / after | Meaning |
|---|---|---|---|
Defaults (RDB |
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 ( |
11 / 11 |
A graceful shutdown writes a final RDB snapshot. |
|
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. |
|
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), withappendfsync everysecas the usual compromise oralwayswhen losing a trigger is unacceptable and the latency is acceptable. Keep RDB as well for backups. -
Never let Redis evict Quartz keys.
redis:8defaults tomaxmemory-policy noeviction, but managed services often ship another one. Only the two lock key families carry a TTL, sovolatile-policies would evict exactly the lock keys, andallkeys-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-writenarrows the window;WAITandWAITAOFcannot 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
requirepassandsslas 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-jobsbehaviour 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.
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");
}
}
| Configuration | Second node starts | Time until it ran the job |
|---|---|---|
|
3 s after the crash |
0 s (immediately at its first trigger scan) |
|
immediately |
28 s and 23 s in the two runs, about the 25 s the orphaned job still needed to finish |
default ( |
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.
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"
<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.
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.
databaseChangeLog:
- include:
file: org/quartz/impl/jdbcjobstore/liquibase.quartz.init.xml
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.
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"));
}
}
}
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"));
}
}
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.
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();
}
}
Source: Spring Boot: Quartz Scheduler
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.
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.
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:
@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.
Pitfalls
| Pitfall | What happens | What to do |
|---|---|---|
Using |
Startup fails with |
Do not use it on Quartz 2.5. If you must, pin |
Setting |
|
The store is always clustered; set |
Leaving the instance id at its default |
The default id is |
|
|
|
Keep |
Values in the |
They come back as strings ( |
Only store ids and flags, read the data from the database in the job |
|
Keys are parsed by splitting on the delimiter, so |
Use |
Empty |
Quartz keys mix with application keys in database 0 |
Always set a prefix such as |
Default Redis persistence |
After a crash of Redis the keys are gone (11 keys before, 0 after a SIGKILL) |
|
Eviction policy other than |
Job or trigger hashes can be evicted silently, or only the lock keys with |
|
Expecting |
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 |
A restarted node did not take over a blocked trigger within 60 s; the sweep is gated by 4 minutes and 10 minutes |
Lower |
|
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 |
|
The Quartz |
Mark only the |
Running the Quartz script as a migration unchanged |
|
Remove them from the Flyway migration or use the bundled Liquibase changelog |
ShedLock with a short |
If the job runs longer, the key expires and a second instance runs it concurrently |
Choose |
What changed
-
Quartz 2.4 to 2.5.
JobDetail.isConcurrentExectionDisallowed()(the misspelled method) was removed andisConcurrentExecutionDisallowed()is the only one, andJobStoregained the abstract methodgetAcquireRetryDelay(int). Both break stores compiled for earlier versions, including this one andquartz-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.0is 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,QuartzTransactionManagerandSchedulerFactoryBeanCustomizermoved toorg.springframework.boot.quartz.autoconfigure, the Flyway and Liquibase starters were added, andDataSourcePropertiesmoved toorg.springframework.boot.jdbc.autoconfigure. Thespring.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
Builderwith thesafeUpdateoption used above and is built against Spring Data Redis 4.1.1.