Quartz on PostgreSQL

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.

PostgreSQL is the reference database of this section: the Bookshelf jobs of every other database page are first run here. This page covers everything that is specific to PostgreSQL once the generic ideas of Job Stores & Persistence are known: the one delegate you must choose and why, what tables_postgres.sql really contains, how to give Quartz its own schema and role, how to create the tables with Flyway or Liquibase, how the SELECT …​ FOR UPDATE row locks behave (shown with the actual statements PostgreSQL logged), what the churn of the Quartz tables does to autovacuum, and a three-node cluster test that starts three JVMs against one container and kills one of them. Clustering as a concept, with the other databases, is on Clustering.

It was written against Quartz 2.5.2, Spring Boot 4.1.1, Spring Framework 7.0.9 and Java 21 (bytecode level; the build ran on JDK 25.0.2 with Maven 3.9.16), PostgreSQL 17.11 and the pgJDBC driver 42.7.13, and verified on 5 October 2026. The database was the image postgres:17, which reported PostgreSQL 17.11 (Debian 17.11-1.pgdg13+2) on aarch64-unknown-linux-gnu (native arm64 under Docker Desktop on Apple Silicon, no emulation). Connection pool: HikariCP 7.0.2. Migration tools: Flyway 12.4.0 and Liquibase 5.0.3, the versions Boot 4.1.1 manages. Tests: Testcontainers 2.0.5 and JUnit 6.0.3. Every behaviour quoted below was run in a test project and every default, SQL statement and method was read from the Quartz 2.5.2 and Spring Framework 7.0.9 sources; results that come from one run say so. Spring Boot 3.5.16 manages the same Quartz 2.5.2 and the same pgJDBC 42.7.13, so everything about Quartz and PostgreSQL is identical; what differs is the package of the Boot types (org.springframework.boot.autoconfigure.quartz instead of org.springframework.boot.quartz.autoconfigure, org.springframework.boot.autoconfigure.jdbc.DataSourceProperties instead of org.springframework.boot.jdbc.autoconfigure.DataSourceProperties), Spring Framework 6.2.19, and the Flyway and Liquibase starters. The page was not re-run on Boot 3.5.

Status and dependencies

PostgreSQL is a supported, official target of Quartz’s JDBC job store: the delegate org.quartz.impl.jdbcjobstore.PostgreSQLDelegate and the script tables_postgres.sql are in the Quartz jar, and clustering works on it (measured below). Nothing community-maintained is involved, in contrast to MongoDB and Redis.

Dependencies of a Spring Boot 4.1 service that keeps Quartz in PostgreSQL (Maven)
<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.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>
<!-- the tables are created by a migration tool, here Flyway -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-database-postgresql</artifactId>
</dependency>

No version number is needed: Boot 4.1.1 manages Quartz 2.5.2, postgresql 42.7.13, Flyway 12.4.0 and Liquibase 5.0.3. spring-boot-starter-jdbc brings HikariCP and Spring’s transaction support; with one DataSource in the context Boot gives it to Quartz by itself. The Flyway and Liquibase starters are Boot 4 names (on Boot 3.5.16 depend on flyway-core or liquibase-core directly, which was not run here). Flyway has shipped each database in its own module since version 10, which is why flyway-database-postgresql is listed.

Table 1. The versions the page was verified with
Component Version Where it comes from

PostgreSQL server

17.11 (postgres:17, Debian build, aarch64)

select version() in the container

pgJDBC (org.postgresql:postgresql)

42.7.13

managed by Boot 4.1.1 and 3.5.16

HikariCP

7.0.2

managed by Boot 4.1.1

Flyway and Liquibase

12.4.0 and 5.0.3

managed by Boot 4.1.1

Testcontainers

2.0.5 (testcontainers-postgresql, testcontainers-junit-jupiter)

managed by Boot 4.1.1

The delegate: PostgreSQLDelegate, and why it is mandatory

A JDBC job store talks to the database through a driver delegate, the class that holds the SQL and the JDBC calls. Quartz chooses nothing for you: if org.quartz.jobStore.driverDelegateClass is not set, Quartz uses StdJDBCDelegate (measured under Boot: the job store reported a null delegate class and used the standard one). StdJDBCDelegate is written for databases whose BLOB columns behave like JDBC BLOBs, and it reads every serialized column (the JobDataMap of a job, the JobDataMap of a trigger, a calendar) with ResultSet.getBlob. PostgreSQL’s script declares those columns as BYTEA, a plain byte string, and pgJDBC maps getBlob only to PostgreSQL’s separate large object facility (columns of type oid), so on a BYTEA column it fails.

What the standard delegate does with a BLOB, and what PostgreSQLDelegate does instead (Quartz 2.5.2 sources, abridged)
// StdJDBCDelegate.getObjectFromBlob (line 3410)
Blob blobLocator = rs.getBlob(colName);
if (blobLocator != null && blobLocator.length() != 0) {
    InputStream binaryInput = blobLocator.getBinaryStream();
    // ... ObjectInputStream.readObject()
}

// PostgreSQLDelegate.getObjectFromBlob (overrides it; getJobDataFromBlob is overridden the same way)
byte[] bytes = rs.getBytes(colName);
if (bytes != null && bytes.length != 0) {
    try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
        obj = in.readObject();
    }
}

The asymmetry is what makes the mistake easy to make: the write side of StdJDBCDelegate uses setBytes, which works on BYTEA, so the first symptom is not at the first insert but at the first read. On the Bookshelf service, with initialize-schema: always and no delegate, the application context did not start. The chain of causes, measured on postgres:17 with pgJDBC 42.7.13:

Starting the Bookshelf service on PostgreSQL without a delegate (root cause first)
org.postgresql.util.PSQLException: Bad value for type long : \xaced0005737200156f72672e71756172747a2e4a6f62446174614d6170...
  at org.quartz.impl.jdbcjobstore.StdJDBCDelegate.getObjectFromBlob(StdJDBCDelegate.java:3414)
  at org.quartz.impl.jdbcjobstore.StdJDBCDelegate.handleJobDetails(StdJDBCDelegate.java:1657)
  at org.quartz.impl.jdbcjobstore.StdJDBCDelegate.selectJobDetail(StdJDBCDelegate.java:844)
  at org.quartz.impl.jdbcjobstore.JobStoreSupport.retrieveJob(JobStoreSupport.java:1403)
org.quartz.JobPersistenceException: Couldn't retrieve job: Bad value for type long : \xaced0005...
org.quartz.JobPersistenceException: Couldn't store trigger 'bookshelf.cacheRefreshTrigger' for 'bookshelf.cacheRefresh' job:Couldn't retrieve job: Bad value for type long : \xaced0005...
BeanCreationException: Error creating bean with name 'quartzScheduler' ...

The text after Bad value for type long is the stored value itself: the hexadecimal aced0005 is the header of Java serialization (ObjectOutputStream), the first four bytes of every job and trigger data column (measured with encode(substring(job_data from 1 for 4), 'hex') on all four Bookshelf jobs). pgJDBC tried to read a BYTEA value as the numeric object id of a large object and complained that it is not a number.

The fix is one property, written under spring.quartz.properties because Boot passes that map straight to Quartz:

Selecting the delegate (application.yml)
spring:
  quartz:
    job-store-type: jdbc
    properties:
      org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate

With it, the same service started, stored and fired the Bookshelf jobs and restarted with the state intact (the persisted runs counter of the overdue-loan job kept growing across a restart). This is the opposite of MySQL and MariaDB, where the standard delegate is the right one, and of Oracle and SQL Server, which need their own; the choice per database is in the support matrix of the generic page. A BYTEA column also means that a JobDataMap is stored as a Java-serialized blob, so every value must be Serializable and a class rename or a field change can make old rows unreadable; Jobs & Job Data explains the consequences and the useProperties alternative, which was not run on PostgreSQL.

tables_postgres.sql

The script is inside the Quartz jar at org/quartz/impl/jdbcjobstore/tables_postgres.sql (7205 bytes in 2.5.2). It creates the eleven QRTZ_ tables, their foreign keys and twenty secondary indexes, and it starts by dropping every table (DROP TABLE IF EXISTS, eleven statements, in dependency order) and ends with COMMIT;. Everything in the first statement list is what makes the script safe for a demo and dangerous for a database that holds a schedule: Boot’s initialize-schema: always runs it on every start and therefore deletes every stored job and trigger on every restart (measured: a job scheduled at runtime survived a restart with never and was gone after a restart with always).

Table 2. The tables, their rows after the Bookshelf run, and the PostgreSQL types that matter
Table What it holds Notes

QRTZ_JOB_DETAILS

One row per JobDetail: class name, durable, non-concurrent, update-data, requests-recovery, JOB_DATA

JOB_DATA is BYTEA; the four flags are BOOL. Four rows after the Bookshelf start.

QRTZ_TRIGGERS

One row per trigger: state, next and previous fire time, priority, misfire instruction, calendar name, JOB_DATA

Times are BIGINT epoch milliseconds, -1 meaning none. MISFIRE_INSTR is SMALLINT. Foreign key to the job.

QRTZ_SIMPLE_TRIGGERS, QRTZ_CRON_TRIGGERS, QRTZ_SIMPROP_TRIGGERS, QRTZ_BLOB_TRIGGERS

The type-specific part of a trigger

Declared foreign keys to QRTZ_TRIGGERS. CRON_EXPRESSION is VARCHAR(120), TIME_ZONE_ID VARCHAR(80).

QRTZ_CALENDARS, QRTZ_PAUSED_TRIGGER_GRPS

Serialized calendars (BYTEA NOT NULL) and the names of paused groups

QRTZ_FIRED_TRIGGERS

One row per trigger that is acquired or executing, with the node that owns it

The churn table. ENTRY_ID is VARCHAR(95). No foreign keys.

QRTZ_SCHEDULER_STATE

One row per live cluster node and its last check-in

Written only when clustered.

QRTZ_LOCKS

The row locks (TRIGGER_ACCESS, STATE_ACCESS)

Two rows per scheduler name, created by Quartz on first use, not by the script (see Locking: SELECT …​ FOR UPDATE on QRTZ_LOCKS).

Every primary key starts with SCHED_NAME (VARCHAR(120)), which is how several schedulers, or several clusters, share one set of tables: the scheduler name (spring.quartz.scheduler-name) is part of every key and every WHERE clause. The entity-relationship picture is on the generic page. Three PostgreSQL details deserve a sentence each:

  • Case. The script uses unquoted upper-case identifiers, which PostgreSQL folds to lower case, so the tables are qrtz_job_details and so on (the monitoring queries below use lower case). Quartz’s own SQL is also unquoted, so the two agree. A tool that creates the tables with quoted upper-case names produces tables Quartz cannot find.

  • Indexes. There are twenty secondary indexes (two on QRTZ_JOB_DETAILS, twelve on QRTZ_TRIGGERS and six on QRTZ_FIRED_TRIGGERS) besides the eleven primary keys. The acquisition query filters on SCHED_NAME, TRIGGER_STATE and NEXT_FIRE_TIME, for which the script has IDX_QRTZ_T_NFT_ST and IDX_QRTZ_T_NEXT_FIRE_TIME. Which one the planner picks depends on statistics: on the four-row Bookshelf table with sequential scans disabled it chose IDX_QRTZ_T_NEXT_FIRE_TIME (an EXPLAIN on a table this small proves nothing about a table with a million triggers). Each index is also something that every trigger update must maintain, which matters for the vacuum section below.

  • Booleans. BOOL is PostgreSQL’s boolean; Quartz binds it with setBoolean, so unlike Oracle with a 23.x driver there is no driver trap here.

A dedicated schema and role

Quartz’s tables do not have to live next to the business tables. A separate schema and a separate role give three things for free: the tables stay out of the application’s namespace (and of its migrations and its ORM), the privileges can be narrower than the application’s, and role-level settings such as search_path or session timeouts apply to Quartz connections only. The SQL below was run on postgres:17 as an administrator; the role name quartz and the password placeholder are examples (use a secret store; the placeholder is not a credential).

01-quartz-role-and-schema.sql: a schema owned by its own role
-- Run once as an administrator, connected to the application database (here: "bookshelf").
CREATE ROLE quartz LOGIN PASSWORD 'change-me-from-a-secret-store';

CREATE SCHEMA quartz AUTHORIZATION quartz;

-- Unqualified QRTZ_* names resolve through the search_path of the quartz role.
ALTER ROLE quartz IN DATABASE bookshelf SET search_path = quartz;

-- Nobody but the owner can see or touch the tables.
REVOKE ALL ON SCHEMA quartz FROM PUBLIC;

Connecting as quartz showed show search_path = quartz and current_schema() = quartz. There are three ways to make Quartz’s unqualified names land in that schema, all of which were run:

Table 3. Three ways to point Quartz at the schema (all measured on PostgreSQL 17)
Mechanism How Result

The role’s search_path (above)

Nothing in Quartz or Spring: the role setting applies to every session of quartz

Works. Needs the role to be the one the application uses.

currentSchema in the JDBC URL

jdbc:postgresql://host:5432/bookshelf?currentSchema=quartz, a pgJDBC connection parameter

Works with any role. The Flyway and Liquibase connections that use the same URL create their tables and history table in that schema too.

A schema-qualified tablePrefix

spring.quartz.properties.org.quartz.jobStore.tablePrefix: quartz.QRTZ_ with the default search_path

Works: the job store read and wrote quartz.qrtz_* from a connection whose search_path was public. The Boot initializer and the Quartz scripts still create unqualified names, so use it only with tables created by a migration.

The page uses currentSchema in the URL (it is visible in the configuration, which search_path is not) together with the role setting as a safety net. A bare tablePrefix such as BKS_QRTZ_ is a different feature, for several applications in one schema; it is described on the generic page.

Separating the migration role from the runtime role

The role that creates the tables does not need to be the role the service runs as. A runtime role that can only read and write rows cannot drop the schedule by mistake, and it cannot be used to change the table definitions. The second script was run with Flyway connecting as quartz (the schema owner, through spring.flyway.user) and the application connecting as quartz_app:

02-quartz-runtime-role.sql: a role that can only use the rows
CREATE ROLE quartz_app LOGIN PASSWORD 'change-me-as-well';

GRANT USAGE ON SCHEMA quartz TO quartz_app;

-- Tables created later by role quartz (Flyway) become usable by quartz_app automatically.
ALTER DEFAULT PRIVILEGES FOR ROLE quartz IN SCHEMA quartz
    GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO quartz_app;

ALTER ROLE quartz_app IN DATABASE bookshelf SET search_path = quartz;
The runtime role in application.yml (the migration connection is separate)
spring:
  datasource:
    url: jdbc:postgresql://${DB_HOST:localhost}:5432/bookshelf?currentSchema=quartz
    username: quartz_app
    password: ${DB_PASSWORD}
  flyway:
    user: quartz
    password: ${DB_MIGRATION_PASSWORD}

Measured: with the schema recreated, the default privileges set and the service started like this, Flyway created eleven tables plus flyway_schema_history, all twelve owned by quartz, the service ran as quartz_app and the Bookshelf jobs fired. UPDATE is what SELECT …​ FOR UPDATE needs, so the four DML privileges are enough. Order matters: ALTER DEFAULT PRIVILEGES …​ IN SCHEMA belongs to the schema, so it disappears with DROP SCHEMA and must be repeated after the schema is recreated (a first run that forgot it failed with permission denied for table qrtz_job_details).

application.yml for production

The configuration below is the one run for the single-node check of this page: it was started as a separate JVM with the YAML as its only configuration file and the four DB_* environment variables set, against the container, with an empty schema. It created the tables through Flyway (flyway_schema_history held version 1, quartz tables), registered one node in QRTZ_SCHEDULER_STATE with CHECKIN_INTERVAL 5000, fired the cache refresh, and pg_stat_activity showed sixteen connections whose application_name was bookshelf-service.

application.yml of the Bookshelf service on PostgreSQL
spring:
  application:
    name: bookshelf-service
  datasource:
    url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/bookshelf?currentSchema=quartz&ApplicationName=bookshelf-service
    username: ${DB_USER}
    password: ${DB_PASSWORD}
    hikari:
      maximum-pool-size: 16            # Quartz threads + 3 (documented rule) plus the application's own needs
      connection-timeout: 10000
  flyway:
    enabled: true                      # V1__quartz_tables.sql lives in classpath:db/migration
  quartz:
    job-store-type: jdbc
    scheduler-name: BookshelfScheduler
    wait-for-jobs-to-complete-on-shutdown: true
    overwrite-existing-jobs: false
    jdbc:
      initialize-schema: never         # the migration tool owns the tables
    properties:
      org.quartz.scheduler.instanceId: AUTO
      org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
      org.quartz.jobStore.isClustered: "true"
      org.quartz.jobStore.clusterCheckinInterval: "5000"   # default 7500; a dead node is noticed after interval + 7500 ms
      org.quartz.threadPool.threadCount: "10"

What each line is for, and where the page treats it:

  • ApplicationName=bookshelf-service is a pgJDBC parameter. Without it the driver reports PostgreSQL JDBC Driver for every session (the statement SET application_name = 'PostgreSQL JDBC Driver' shows in the server log), which makes pg_stat_activity useless for telling the service from a console. With it, select application_name, count(*) from pg_stat_activity showed the sixteen Quartz-pool connections under the service’s name.

  • maximum-pool-size: 16 — Hikari opens the whole pool at start unless minimum-idle is lowered: the measured count above was sixteen connections for one idle node. See Connections: the budget of a cluster for the budget of a cluster.

  • scheduler-name is part of every key in the tables; all nodes of one cluster must share it, and two different applications that share tables must not.

  • initialize-schema: never hands the tables to the migration tool; Flyway and Liquibase follow.

  • wait-for-jobs-to-complete-on-shutdown: true lets a node finish what it is running on a graceful stop, so only a crash needs the recovery described in Testcontainers tests for the Bookshelf jobs.

  • overwrite-existing-jobs: false keeps operator changes (a paused trigger) across a deployment, as on the generic page.

  • instanceId: AUTO makes Quartz build the id from the host name and the epoch milliseconds (so two nodes started in the same millisecond on one host could collide, an unlikely event that the generic clustering page discusses).

  • The delegate and isClustered need no further explanation; every other spring.quartz.* property is on Spring Boot Configuration.

Flyway

A Flyway migration is the Quartz script without its DROP TABLE lines. The COMMIT; at the end of the script is also removed (Flyway runs each migration in a transaction of its own). The whole pipeline, run against the 2.5.2 jar, produces a file that is byte-for-byte the migration used by every test on this page:

Producing the migration from the Quartz jar
mkdir -p src/main/resources/db/migration
unzip -p quartz-2.5.2.jar org/quartz/impl/jdbcjobstore/tables_postgres.sql \
  | sed -e '/^DROP TABLE/d' -e '/^COMMIT;/d' > src/main/resources/db/migration/V1__quartz_tables.sql

The YAML of the previous section is the whole Boot configuration: spring.flyway.enabled defaults to true and the default location is classpath:db/migration. Boot starts the scheduler after Flyway (the scheduler bean depends on database initialization, which includes Flyway and Liquibase): with an empty schema and initialize-schema: never the migration ran first and the jobs fired from the tables it had just created, with no @DependsOn. Measured with the dedicated quartz role and currentSchema=quartz: eleven qrtz_* tables, flyway_schema_history in the same schema (one row, version 1, quartz tables, success true), and the Bookshelf jobs firing. Which tool to choose is on Choosing a Migration Tool; Flyway has no page of its own in that section, see Schema Evolution for the concepts.

When a Quartz upgrade changes the DDL, you write V2__…​ yourself. Nothing in Quartz or Boot will ever alter the tables of a database it did not create: that is the reason to own the migration.

Liquibase

Liquibase offers two routes, both run on postgres:17 with Liquibase 5.0.3 and currentSchema=quartz. Boot looks for classpath:/db/changelog/db.changelog-master.yaml by default and needs spring.liquibase.change-log for any other file, and both Flyway and Liquibase start automatically when on the classpath, so the measured runs switched the other one off with spring.flyway.enabled=false or spring.liquibase.enabled=false.

Route 1: the script as a change set (db/changelog/db.changelog-master.yaml)
databaseChangeLog:
  - changeSet:
      id: quartz-tables
      author: bookshelf
      changes:
        - sqlFile:
            path: db/changelog/quartz-tables.sql      # the same file as V1__quartz_tables.sql
            splitStatements: true
            endDelimiter: ;
            stripComments: true
Route 2: the changelog that ships inside the Quartz jar (db/changelog/db.changelog-master.xml)
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
    xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">

    <include file="org/quartz/impl/jdbcjobstore/liquibase.quartz.init.xml"/>
</databaseChangeLog>

Route 2 needs spring.liquibase.change-log: classpath:db/changelog/db.changelog-master.xml in the YAML (the measured run kept that file under another directory, with the same property). The file org/quartz/impl/jdbcjobstore/liquibase.quartz.init.xml is in the Quartz jar (17219 bytes in 2.5.2), not in Spring Boot. It declares one change set (id="quartz-init", author="quartz"), maps the BLOB type to BYTEA for dbms="postgresql", and needs no DROP. Both routes created the eleven tables, databasechangelog and databasechangeloglock, one row in databasechangelog (quartz-tables from the YAML, quartz-init from the XML) and the Bookshelf jobs fired with PostgreSQLDelegate. The results compared table by table:

Table 4. The two Liquibase routes against the SQL script (queries on information_schema and pg_indexes, PostgreSQL 17)
Compared Result

Columns (name, type, nullability, length) of the eleven tables

Identical, 79 columns

Primary keys and foreign keys

Identical (eleven primary keys, one foreign key on QRTZ_TRIGGERS and one on each of the four trigger-type tables)

Secondary indexes

The SQL script creates 20. The bundled XML creates 19: IDX_QRTZ_T_J (SCHED_NAME, JOB_NAME, JOB_GROUP on QRTZ_TRIGGERS) is missing

That index is the one on the columns of the foreign key from a trigger to its job, which PostgreSQL does not create by itself. Without it, looking up the triggers of a job (getTriggersOfJob, removing a job) and the foreign-key check when a job row is deleted scan the trigger table. With a handful of rows nobody notices; with a large schedule it can matter. If you choose the bundled changelog on PostgreSQL, add the index in a change set of your own:

Adding the index the bundled changelog omits
CREATE INDEX IDX_QRTZ_T_J ON QRTZ_TRIGGERS (SCHED_NAME, JOB_NAME, JOB_GROUP);

Choose the sqlFile route when you want exactly what Quartz’s PostgreSQL script defines, the bundled changelog when you want one file that follows the Quartz release (and accept the one index). The concepts are on Liquibase: Getting Started and Liquibase: Tracking and Locking. Quartz’s main branch has an unreleased change about primary keys and Liquibase (pull request #1489, not in 2.5.2); read its release notes before relying on the bundled changelog after the next Quartz upgrade.

A second data source: @QuartzDataSource and @QuartzTransactionManager

When the business data is elsewhere (another PostgreSQL database, or MongoDB as on Quartz with MongoDB), Quartz gets its own data source. Boot’s rule has two steps: job-store-type: jdbc is honoured only when there is exactly one candidate DataSource (a single bean, or one marked @Primary), and then the job store uses the DataSource annotated @QuartzDataSource if there is one, else that candidate. The first listing configures an application database as the primary data source and a dedicated quartzdb database for Quartz; both were PostgreSQL 17 databases in one container, with a quartz role that owns quartzdb.

Two data sources: the application’s is primary, Quartz’s is marked @QuartzDataSource (Boot 4.1)
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.autoconfigure.DataSourceProperties;
import org.springframework.boot.quartz.autoconfigure.QuartzDataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

@Configuration(proxyBeanMethods = false)
class DataSourcesConfiguration {

    @Bean @Primary @ConfigurationProperties("bookshelf.datasource")
    DataSourceProperties appDataSourceProperties() { return new DataSourceProperties(); }

    @Bean @Primary
    HikariDataSource appDataSource(@Qualifier("appDataSourceProperties") DataSourceProperties properties) {
        return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
    }

    @Bean @ConfigurationProperties("bookshelf.quartz.datasource")
    DataSourceProperties quartzDataSourceProperties() { return new DataSourceProperties(); }

    @Bean @QuartzDataSource
    HikariDataSource quartzDataSource(@Qualifier("quartzDataSourceProperties") DataSourceProperties properties) {
        return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
    }
}
The matching properties (application.yml)
bookshelf:
  datasource:
    url: jdbc:postgresql://${DB_HOST:localhost}:5432/bookshelf
    username: bookshelf_app
    password: ${APP_DB_PASSWORD}
  quartz:
    datasource:
      url: jdbc:postgresql://${DB_HOST:localhost}:5432/quartzdb
      username: quartz
      password: ${QUARTZ_DB_PASSWORD}
spring:
  quartz:
    job-store-type: jdbc
    properties:
      org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate

Measured with these two beans and initialize-schema: always: the job store was LocalDataSourceJobStore, the Bookshelf jobs fired, quartzdb had eleven qrtz tables and the application database had none. The failure mode of the other configuration is on the generic page and was reproduced there: with two DataSource beans and no @Primary, job-store-type: jdbc makes Boot fall back to the RAM store without an error, and marking one of them @QuartzDataSource does not change that (measured with two H2 beans, one of them @QuartzDataSource, none @Primary: RAMJobStore, supportsPersistence=false). The @QuartzDataSource bean must be accompanied by a @Primary application data source, as in the listing above.

The transaction the data source does not give you

A second data source changes what a Spring transaction covers. A @Transactional method of the application is bound to the primary transaction manager, which manages the application’s data source; Quartz’s connections come from another pool, so the application’s transaction does not include them. Boot’s counterpart of @QuartzDataSource is @QuartzTransactionManager. The table shows what each setup sends to the database when a loan-A reminder is scheduled inside a TransactionTemplate (statements read from the server log with log_statement = all):

Table 5. Scheduling a reminder inside a transaction, with the Quartz connection in autocommit or in a transaction (measured)
Setup Statements Quartz sent

TransactionTemplate on the application’s transaction manager, Quartz on @QuartzDataSource

No BEGIN, no COMMIT. Every statement ran in autocommit: the SELECT …​ FOR UPDATE on the lock row released its lock at once, and the QRTZ_JOB_DETAILS row and the QRTZ_TRIGGERS row were committed separately.

TransactionTemplate on a @QuartzTransactionManager bound to the Quartz data source

One transaction: BEGIN, the lock, the job insert, the trigger inserts, COMMIT.

A transaction manager for the Quartz data source, used around scheduler calls (Boot 4.1)
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.quartz.autoconfigure.QuartzDataSource;
import org.springframework.boot.quartz.autoconfigure.QuartzTransactionManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;

import javax.sql.DataSource;

// A separate class, not a subclass of DataSourcesConfiguration: it only consumes the two DataSource beans.
@Configuration(proxyBeanMethods = false)
class QuartzTransactionConfiguration {

    @Bean @Primary
    PlatformTransactionManager appTransactionManager(@Qualifier("appDataSource") DataSource appDataSource) {
        return new DataSourceTransactionManager(appDataSource);
    }

    @Bean @QuartzTransactionManager
    PlatformTransactionManager quartzTransactionManager(@QuartzDataSource DataSource quartzDataSource) {
        return new DataSourceTransactionManager(quartzDataSource);
    }
}
The service that schedules reminders, wrapping the scheduler calls in the Quartz transaction
import org.quartz.JobDetail;
import org.quartz.Scheduler;
import org.quartz.Trigger;
import org.springframework.boot.quartz.autoconfigure.QuartzTransactionManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;

@Service
class ReminderScheduling {

    private final Scheduler scheduler;
    private final TransactionTemplate quartzTransaction;

    ReminderScheduling(Scheduler scheduler,
                       @QuartzTransactionManager PlatformTransactionManager quartzTransactionManager) {
        this.scheduler = scheduler;
        this.quartzTransaction = new TransactionTemplate(quartzTransactionManager);
    }

    void schedule(JobDetail job, Trigger trigger) {
        quartzTransaction.executeWithoutResult(status -> {
            try {
                scheduler.scheduleJob(job, trigger);    // one BEGIN ... COMMIT on the Quartz database
            } catch (org.quartz.SchedulerException e) {
                throw new IllegalStateException(e);     // rolls the Quartz transaction back
            }
        });
    }
}

Two consequences. First, the business write and the Quartz write are two transactions on two databases; if the service must never create a loan without its reminder, make the job idempotent and reconcile, as the MongoDB page describes, instead of expecting one atomic commit. Second, even with a single data source this matters, as the next section shows.

Locking: SELECT …​ FOR UPDATE on QRTZ_LOCKS

Quartz protects its tables with two named locks, implemented as rows of QRTZ_LOCKS that a transaction locks with SELECT …​ FOR UPDATE. The class is StdRowLockSemaphore; the statement is a constant in its source:

The lock statement and the insert of a missing lock row (StdRowLockSemaphore, Quartz 2.5.2)
public static final String SELECT_FOR_LOCK = "SELECT * FROM "
        + TABLE_PREFIX_SUBST + TABLE_LOCKS + " WHERE " + COL_SCHEDULER_NAME + " = " + SCHED_NAME_SUBST
        + " AND " + COL_LOCK_NAME + " = ? FOR UPDATE";

public static final String INSERT_LOCK = "INSERT INTO "
    + TABLE_PREFIX_SUBST + TABLE_LOCKS + "(" + COL_SCHEDULER_NAME + ", " + COL_LOCK_NAME + ") VALUES ("
    + SCHED_NAME_SUBST + ", ?)";

Three properties of this mechanism follow from the source and were all observed:

  • The rows are created on demand. If the SELECT finds no row, StdRowLockSemaphore inserts it (the log shows INSERT INTO QRTZ_LOCKS(SCHED_NAME, LOCK_NAME) VALUES ('BookshelfCluster', $1) right after the first FOR UPDATE). The script does not insert them, so a freshly migrated database has an empty QRTZ_LOCKS, and after a Bookshelf run it held exactly TRIGGER_ACCESS and STATE_ACCESS for the scheduler name (STATE_ACCESS only when clustered). If two nodes race on the very first insert, the loser retries: maxRetry is 3 and retryPeriod 1000 ms by default (three attempts in all, a second apart).

  • The rows are never updated. After 975 firings on a busy single node, QRTZ_LOCKS had one live tuple (TRIGGER_ACCESS; two in a clustered run), zero updates and zero dead tuples (pg_stat_user_tables). A row lock of this kind writes the locking transaction’s id into the existing row version instead of creating a new one, so the lock table does not bloat and needs no tuning (see Vacuuming and bloat of the churn tables).

  • A failure to get the lock is retried, then thrown. On a SQLException the semaphore rolls the connection back, sleeps retryPeriod and tries again, for maxRetry attempts in all, and finally throws LockException: Failure obtaining db row lock.

Which operation takes which lock

The two lock names are the constants LOCK_TRIGGER_ACCESS (TRIGGER_ACCESS) and LOCK_STATE_ACCESS (STATE_ACCESS) in JobStoreSupport. TRIGGER_ACCESS protects the scheduling tables; STATE_ACCESS protects the cluster state.

Table 6. Where Quartz 2.5.2 takes the locks (JobStoreSupport source, confirmed by statement logs)
Operation Lock Detail

acquireNextTriggers (scheduler thread)

none by default

Locks TRIGGER_ACCESS only when acquireTriggersWithinLock is true or the batch size is above one (the source: isAcquireTriggersWithinLock() || maxCount > 1). The default is false and the batch size is 1, and the log of such a run shows no FOR UPDATE during acquisition.

triggersFired (scheduler thread, after the wait)

TRIGGER_ACCESS

Re-checks that the trigger is still ACQUIRED, marks the fired row EXECUTING, computes the next fire time.

triggeredJobComplete (worker thread, after the job)

TRIGGER_ACCESS

Updates the job data when @PersistJobDataAfterExecution is set, frees blocked triggers of a @DisallowConcurrentExecution job, deletes the fired row.

Storing jobs and triggers, scheduleJob, rescheduleJob, deleteJob, pause and resume

TRIGGER_ACCESS

For plain inserts only when lockOnInsert is true (the default); replacing or updating always locks.

Misfire handling (MisfireHandler thread)

TRIGGER_ACCESS, only if something misfired

It first counts misfired triggers without a lock and skips the lock when the count is zero.

Cluster check-in, normal case

none

SELECT * FROM QRTZ_SCHEDULER_STATE and an UPDATE of LAST_CHECKIN_TIME, no lock.

First check-in of a node, and recovery of a failed node

STATE_ACCESS, then TRIGGER_ACCESS for the recovery

Recovery needs both because it rewrites trigger and fired-trigger rows.

The same source says why acquisition can do without a lock: the update that claims a trigger is conditional (it only succeeds while the state is still WAITING). The Javadoc of setAcquireTriggersWithinLock calls the lock the behaviour before Quartz 1.6.3, considers it superfluous for most databases because of that update, and says that with batch acquisition it must be used for every database. With the defaults, the lock-free path is also what makes a cluster scale: only the short fire and complete transactions serialize.

What one firing looks like in PostgreSQL’s log

The sequence below is read from the PostgreSQL log of a real run (log_statement = all, Boot 4.1.1, one node, default settings, a one-second cacheRefresh trigger and a non-concurrent cron trigger), with the statements shortened. Statements from one connection are grouped.

The three transactions of one firing (statement log, abridged)
-- connection 268: the scheduler thread acquires the trigger. No FOR UPDATE.
BEGIN
SELECT TRIGGER_NAME, TRIGGER_GROUP, NEXT_FIRE_TIME, PRIORITY FROM QRTZ_TRIGGERS WHERE SCHED_NAME = 'BookshelfCluster' AND TRIGGER_STATE = $1 AND NEXT_FIRE_TIME <= $2 AND (MISFIRE_INSTR = -1 OR (...)) ORDER BY NEXT_FIRE_TIME ASC, PRIORITY DESC
SELECT * FROM QRTZ_TRIGGERS WHERE SCHED_NAME = ... AND TRIGGER_NAME = $1 AND TRIGGER_GROUP = $2
SELECT * FROM QRTZ_SIMPLE_TRIGGERS WHERE ...
SELECT * FROM QRTZ_JOB_DETAILS WHERE ...
UPDATE QRTZ_TRIGGERS SET TRIGGER_STATE = $1 WHERE SCHED_NAME = ... AND TRIGGER_NAME = $2 AND TRIGGER_GROUP = $3 AND TRIGGER_STATE = $4
INSERT INTO QRTZ_FIRED_TRIGGERS (SCHED_NAME, ENTRY_ID, TRIGGER_NAME, TRIGGER_GROUP, INSTANCE_NAME, FIRED_TIME, SCHED_TIME, STATE, ...) VALUES (...)
COMMIT

-- connection 268 again, when the fire time is reached: the trigger is fired. Lock first.
BEGIN
SELECT * FROM QRTZ_LOCKS WHERE SCHED_NAME = 'BookshelfCluster' AND LOCK_NAME = $1 FOR UPDATE
SELECT TRIGGER_STATE FROM QRTZ_TRIGGERS WHERE ...
SELECT * FROM QRTZ_JOB_DETAILS WHERE ...
UPDATE QRTZ_FIRED_TRIGGERS SET INSTANCE_NAME = $1, FIRED_TIME = $2, SCHED_TIME = $3, STATE = $4, JOB_NAME = $5, ... WHERE ...
UPDATE QRTZ_TRIGGERS SET JOB_NAME = $1, JOB_GROUP = $2, DESCRIPTION = $3, NEXT_FIRE_TIME = $4, PREV_FIRE_TIME = $5, TRIGGER_STATE = $6, ... WHERE ...
UPDATE QRTZ_SIMPLE_TRIGGERS SET REPEAT_COUNT = $1, REPEAT_INTERVAL = $2, TIMES_TRIGGERED = $3 WHERE ...
COMMIT

-- connection 267: a worker thread, after the job returned. Lock again.
BEGIN
SELECT * FROM QRTZ_LOCKS WHERE SCHED_NAME = 'BookshelfCluster' AND LOCK_NAME = $1 FOR UPDATE
DELETE FROM QRTZ_FIRED_TRIGGERS WHERE SCHED_NAME = 'BookshelfCluster' AND ENTRY_ID = $1
COMMIT

For the non-concurrent cron job (OverdueLoanRemindersJob) the fire transaction also ran three UPDATE QRTZ_TRIGGERS SET TRIGGER_STATE = …​ WHERE …​ JOB_NAME = $2 …​ statements that block the job’s other triggers, and its completion transaction ran the matching unblocking updates and an UPDATE QRTZ_JOB_DETAILS SET JOB_DATA because of @PersistJobDataAfterExecution. With org.quartz.jobStore.acquireTriggersWithinLock: "true" the acquisition transaction began with the SELECT …​ FOR UPDATE on the lock row, followed by the same statements. BEGIN and COMMIT appear because JobStoreCMT switches autocommit off for the connections of its scheduler threads. The transaction ids in the log (xid column of the log line, zero until the first write) confirm that each COMMIT ends a transaction that did all the writes above it.

sequenceDiagram autonumber participant S as Scheduler thread participant DB as PostgreSQL, schema quartz participant W as Worker thread Note over S,DB: Acquire: one transaction, no lock by default S->>DB: BEGIN opt acquireTriggersWithinLock is true or batch size above 1 S->>DB: SELECT from QRTZ_LOCKS for TRIGGER_ACCESS FOR UPDATE end S->>DB: SELECT the next WAITING triggers due within the window S->>DB: UPDATE QRTZ_TRIGGERS to ACQUIRED where the state is still WAITING DB-->>S: 1 row changed means this node owns it, 0 rows means another node won S->>DB: INSERT INTO QRTZ_FIRED_TRIGGERS with state ACQUIRED S->>DB: COMMIT Note over S: waits for the fire time and holds no connection Note over S,DB: Fire: TRIGGER_ACCESS row lock S->>DB: BEGIN S->>DB: SELECT from QRTZ_LOCKS for TRIGGER_ACCESS FOR UPDATE S->>DB: SELECT the trigger state, it must still be ACQUIRED, and the job S->>DB: UPDATE QRTZ_FIRED_TRIGGERS to EXECUTING S->>DB: UPDATE QRTZ_TRIGGERS with the next fire time and the type table S->>DB: COMMIT releases the lock S->>W: hand the job to a worker thread W->>W: the job runs, no transaction and no lock Note over W,DB: Complete: TRIGGER_ACCESS row lock again W->>DB: BEGIN W->>DB: SELECT from QRTZ_LOCKS for TRIGGER_ACCESS FOR UPDATE W->>DB: UPDATE QRTZ_JOB_DETAILS data only with PersistJobDataAfterExecution W->>DB: DELETE FROM QRTZ_FIRED_TRIGGERS W->>DB: COMMIT
Figure 1. How a trigger is acquired, fired and completed, and where the row locks are

Source: JobStoreSupport source at v2.5.2, checked against the statement log shown above.

The three settings that change the picture

  • acquireTriggersWithinLock (default false): described above. Leave it false unless you raise org.quartz.scheduler.batchTriggerAcquisitionMaxCount above 1, in which case the source locks anyway.

  • lockOnInsert (default true): measured with the same scheduleJob call twice. With the default, the insert transaction began with the FOR UPDATE on TRIGGER_ACCESS; with org.quartz.jobStore.lockOnInsert: "false" it began directly with the SELECT JOB_NAME FROM QRTZ_JOB_DETAILS. The Javadoc of the property calls true the safest value, says that some databases (SQL Server is named) need it to avoid deadlocks under load, and warns that with false the isolation between scheduling and trigger acquisition is left entirely to the database and may give unusual scheduling behaviour; the same Javadoc advertises a significant speed-up of adding jobs and triggers. Leave it true unless you have measured a problem with job insertion.

  • selectWithLockSQL (default none): the statement above is used. Only SQL Server needs a replacement (WITH (UPDLOCK, ROWLOCK)), which the source sets for MSSQLDelegate; PostgreSQL does not.

Isolation level

Quartz does not set an isolation level by default, so a Quartz connection runs at the server’s default, which on PostgreSQL is READ COMMITTED (the log of the run above contains no SET SESSION CHARACTERISTICS statement). That is the level the row-lock design assumes: a transaction that waits on FOR UPDATE re-reads the row after the lock is granted and proceeds. Two properties exist to change it, and both were run on the busy Bookshelf workload (twenty seconds each) to see what they do:

Table 7. The isolation properties under Boot’s LocalDataSourceJobStore (measured)
Property What the server log showed

org.quartz.jobStore.txIsolationLevelReadCommitted: "true"

No SET statement (the connection was already READ COMMITTED), but one extra SHOW TRANSACTION ISOLATION LEVEL round trip per Quartz transaction: 1957 of them in twenty seconds. A cost with no effect on PostgreSQL.

org.quartz.jobStore.txIsolationLevelSerializable: "true"

Ten SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL SERIALIZABLE statements, each restored afterwards, and none for the scheduler thread’s transactions. Under Boot, JobStoreCMT honours only the read-committed flag for the connections of its scheduler threads, so this property reaches only the connections obtained through Spring for the scheduler API calls. No could not serialize access error occurred, but the run was a single node.

Leave both unset: PostgreSQL’s default is the level the lock design was written for. The wider treatment of isolation and locking in application code is on Transaction Isolation & Locking and SQL Transactions.

Scheduler calls outside a transaction

The lock only protects what happens while the transaction that took it is open. Spring’s LocalDataSourceJobStore sets dontSetAutoCommitFalse, and the connections it hands out for scheduler API calls come from Spring’s transaction handling: inside a Spring-managed transaction they take part in it, outside one they are plain autocommit connections. The same scheduleJob call, measured twice in one JVM on a quiet scheduler:

One scheduleJob call, without and with a Spring transaction (statement log, abridged)
-- called from a plain method: no BEGIN, no COMMIT, every statement autocommits
SELECT * FROM QRTZ_LOCKS WHERE SCHED_NAME = 'TxProbe' AND LOCK_NAME = $1 FOR UPDATE
SELECT JOB_NAME FROM QRTZ_JOB_DETAILS WHERE ...
INSERT INTO QRTZ_JOB_DETAILS (...) VALUES (...)
INSERT INTO QRTZ_TRIGGERS (...) VALUES (...)
INSERT INTO QRTZ_SIMPLE_TRIGGERS (...) VALUES (...)

-- called inside TransactionTemplate.executeWithoutResult: one transaction
BEGIN
SELECT * FROM QRTZ_LOCKS WHERE SCHED_NAME = 'TxProbe' AND LOCK_NAME = $1 FOR UPDATE
SELECT JOB_NAME FROM QRTZ_JOB_DETAILS WHERE ...
INSERT INTO QRTZ_JOB_DETAILS (...) VALUES (...)
INSERT INTO QRTZ_TRIGGERS (...) VALUES (...)
INSERT INTO QRTZ_SIMPLE_TRIGGERS (...) VALUES (...)
COMMIT

In the first form the FOR UPDATE lock is released before the insert, and the job and its trigger are two commits: a crash between them leaves a job without a trigger. PostgreSQL does not complain. Spring’s own Javadoc says to perform all scheduler operations inside Spring-managed transactions, and mentions Oracle as a database where the lock without a transaction fails outright. In the Bookshelf service, the method that creates the per-member reminder when a loan is made should therefore be @Transactional (with @QuartzTransactionManager named explicitly when Quartz has its own data source, see the previous section), which also lets the reminder commit or roll back together with the loan row when both are in the same database. The scheduler API is covered on Dynamic Scheduling & Management.

What happens when the lock is held

Because triggersFired and triggeredJobComplete wait for TRIGGER_ACCESS, anything that holds that row lock stalls the whole cluster’s firing. In a test run the lock was held from a second session (BEGIN; SELECT …​ FOR UPDATE on the TRIGGER_ACCESS row of the scheduler, no commit) while a one-second trigger was running on a single node, and recorded what PostgreSQL and the scheduler did:

Table 8. The TRIGGER_ACCESS row lock held by another session for six seconds (measured)
Observation Result

pg_stat_activity three seconds into the hold

A Quartz backend was active with wait_event_type = Lock and wait_event = transactionid, its query being the SELECT * FROM QRTZ_LOCKS …​ FOR UPDATE. (With two waiters, the second showed Lock and tuple.)

QRTZ_FIRED_TRIGGERS during the hold

One row in state ACQUIRED: the trigger had been claimed (that transaction needs no lock) but could not be fired.

Executions during the hold

None. The first execution came 6.2 seconds after the lock was taken, 0.1 s after its release.

After the release

A burst: the trigger fired once for every second it had missed, earliest scheduled time first, and had caught up 2.4 seconds after the release (thirteen executions, with scheduled times from four seconds before the lock to eight seconds after it). They are not misfires: a JDBC store’s misfireThreshold is 60000 ms, so six seconds of lateness is only lateness.

Who is waiting for a Quartz lock (the query used in the experiment)
SELECT pid, state, wait_event_type, wait_event, left(query, 70) AS query
FROM pg_stat_activity
WHERE datname = 'bookshelf' AND usename = 'quartz'
  AND wait_event_type IS NOT NULL AND state = 'active'
ORDER BY pid;

Two role-level settings, both applied with ALTER ROLE quartz IN DATABASE bookshelf SET …​ (which is one more reason for a dedicated role), react to a stuck holder, and the measurements disagree with the intuition:

  • lock_timeout = '1500ms' made waiters give up. With a six-second hold nothing visible happened: StdRowLockSemaphore retried (three attempts in all, one second apart, each cut short by the timeout) and the lock was granted after the release, with firing resuming 0.04 s after the release. With a sixteen-second hold the retries ran out, the scheduler thread logged LockException: Failure obtaining db row lock: ERROR: canceling statement due to lock timeout through org.quartz.core.ErrorLogger, and firing resumed only 28.9 seconds after the lock was taken, 12.9 s after its release. The same sixteen-second hold without lock_timeout produced no error and resumed 0.02 s after the release. The delay is consistent with the job store’s dbRetryInterval (15000 ms), the period its retry loops sleep for after a failure (retryExecuteInNonManagedTXLock in JobStoreSupport). A short lock_timeout therefore turns a long stall into a longer one; if you set it, set it well above the longest stall you tolerate.

  • idle_in_transaction_session_timeout ends the session of a client that opened a transaction and stopped (FATAL: terminating connection due to idle-in-transaction timeout, measured with a two-second value on a test session that held the lock row). It is the setting that frees a lock held by a forgotten transaction and the one that keeps a stuck transaction from pinning the vacuum horizon (next section). Because it applies per role it affects Quartz’s own sessions too, so choose a value above the longest transaction your application keeps open around scheduler calls.

Vacuuming and bloat of the churn tables

PostgreSQL never updates a row in place: an UPDATE writes a new row version and leaves the old one for VACUUM to reclaim, and a DELETE leaves a dead version too. Quartz’s access pattern is a stream of tiny updates and deletes on a few tiny tables, so the interesting question is how many dead versions it produces and whether autovacuum keeps up. It was measured with a deliberately busy scheduler: eight simple triggers firing every 250 ms each, on one node with ten worker threads and instantly finishing jobs, in a fresh schema, for sixty seconds, reading pg_stat_user_tables and the table sizes every twenty seconds. The container ran autovacuum_naptime = 5s (the default is one minute) so that the effect of autovacuum shows within the run. The number of executions differed from run to run (between 962 and 1377 in sixty seconds, on a laptop doing other work), so compare dead tuples per execution, not absolute rates.

The statistics query used for the measurements
SELECT relname, n_live_tup, n_dead_tup, n_tup_ins, n_tup_upd, n_tup_hot_upd, n_tup_del,
       autovacuum_count, pg_total_relation_size(relid) AS total_bytes
FROM pg_stat_user_tables
WHERE schemaname = 'quartz'
ORDER BY relname;
Table 9. Churn of one run of 975 executions, autovacuum disabled on the Quartz tables (measured)
Table Inserts Updates HOT updates Deletes Dead tuples Total size at start and at the end

qrtz_triggers

12

1957

0

0

1957

224 KiB to 464 KiB

qrtz_fired_triggers

979

978

0

977

1955

128 KiB to 296 KiB

qrtz_simple_triggers

9

978

978

0

41

32 KiB to 32 KiB

qrtz_locks

1

0

0

0

0

24 KiB to 24 KiB

The inserts into qrtz_triggers (12) and qrtz_simple_triggers (9) are the startup registration of the Bookshelf triggers and the eight benchmark triggers; everything after that is churn. What the numbers say, per table:

  • qrtz_triggers gets two updates per execution (the acquire and the fire) and none of them is a HOT update, because every update changes an indexed column (TRIGGER_STATE, NEXT_FIRE_TIME) and the table has twelve secondary indexes (and the primary key) that must each get a new entry. This is the table whose indexes bloat.

  • qrtz_fired_triggers is the pure churn table: one insert, one update and one delete per execution, so two dead versions per execution.

  • qrtz_simple_triggers is updated on every firing of a simple trigger but every update is HOT, so its dead versions are pruned inside the page (41 left of 978).

  • qrtz_locks is untouched, as argued in Locking: SELECT …​ FOR UPDATE on QRTZ_LOCKS. A cluster’s qrtz_scheduler_state showed the same HOT pattern: in a twenty-second run with a 500 ms check-in, 40 updates, 40 of them HOT.

With autovacuum switched on, the same kind of load stayed small. The three configurations ran the same sixty seconds one after the other:

Table 10. Dead tuples and size with autovacuum off, at its defaults, and with per-table settings (measured, sixty seconds each)
Configuration Executions Dead tuples at the end (qrtz_triggers, qrtz_fired_triggers) Autovacuum runs on qrtz_triggers Total size at the end (qrtz_triggers, qrtz_fired_triggers)

autovacuum off

975

1957 and 1955

0

464 KiB and 296 KiB

defaults (5 s naptime)

962

126 and 109

11

432 KiB and 168 KiB

per-table settings below

1155

32 and 26

12

448 KiB and 200 KiB

The per-table settings of the last row are ordinary storage parameters. They were applied with ALTER TABLE to the Quartz tables before the load started:

Making autovacuum vacuum small, hot tables after fifty dead tuples instead of 20 percent of the table
ALTER TABLE quartz.qrtz_triggers SET (
    autovacuum_vacuum_scale_factor = 0,
    autovacuum_vacuum_threshold = 50,
    autovacuum_analyze_scale_factor = 0,
    autovacuum_analyze_threshold = 200);
-- the same four settings were applied to qrtz_fired_triggers, qrtz_simple_triggers,
-- qrtz_scheduler_state, qrtz_locks and qrtz_job_details in the measured run

The honest reading of the table is that, for tables this small, the default autovacuum threshold (50 dead tuples plus 20 percent of the table) is already small enough: the defaults left about a hundred dead tuples where the per-table settings left about thirty, and both stayed within a few hundred KiB. Do not tune what you have not measured. The per-table settings are worth having when the scheduler is much busier than this one or the trigger table is large, and as a statement of intent. What matters far more is whether VACUUM is able to remove the dead versions, and that depends on the oldest transaction in the database, not on Quartz.

The old transaction

A dead row version can only be removed when no open transaction could still need to see it. The run was repeated with one unrelated session that had opened a transaction, taken a transaction id and gone idle (BEGIN; select txid_current();), with default autovacuum settings:

Table 11. The same load with an idle transaction open for the whole run (measured, 1377 executions)
Moment Dead tuples (qrtz_triggers, qrtz_fired_triggers) Autovacuum runs on qrtz_triggers Total size (qrtz_triggers, qrtz_fired_triggers)

After 20 s

1298 and 1300

3

824 KiB and 440 KiB

After 60 s

2730 and 2718

11

1528 KiB and 840 KiB

12 s after the idle transaction ended

690 and 88

13 (and 14 on qrtz_fired_triggers)

1512 KiB and 640 KiB

Autovacuum ran (eleven times) and removed nothing, because the idle transaction held the horizon: the dead-tuple counts kept growing, almost as in the run with autovacuum switched off, and the tables reached about three times the largest size of any run without the old transaction (1528 KiB against 464 KiB for qrtz_triggers). After the transaction ended the dead-tuple counts started to fall (they are cumulative statistics and lag a little) but the files did not shrink: VACUUM makes the space reusable and only truncates empty pages at the end of a table. In a real database the transaction that holds the horizon is rarely idle by accident: a long report, a forgotten console session, a logical replication slot that nobody consumes, a standby with hot_standby_feedback. Quartz is the first to suffer, because it rewrites the same few rows continuously. The defences, in order of strength:

  • Set idle_in_transaction_session_timeout for application roles (see Locking: SELECT …​ FOR UPDATE on QRTZ_LOCKS for its interaction with Quartz) and statement_timeout or a read replica for reports.

  • Monitor the age of the oldest transaction (pg_stat_activity.xact_start) and the dead-tuple counts above; alert when n_dead_tup of qrtz_fired_triggers or qrtz_triggers keeps growing while autovacuum_count keeps increasing.

  • Keep Quartz in its own database when other workloads run long transactions: the vacuum horizon is per database, so a separate schema is cosmetic here and a separate database (the @QuartzDataSource section) is real isolation (replication slots and prepared transactions are the exceptions: they hold the horizon of the whole cluster).

  • To return the space of an already bloated table: VACUUM (VERBOSE) makes it reusable; VACUUM FULL or pg_repack shrink it but VACUUM FULL takes an ACCESS EXCLUSIVE lock, which blocks the scheduler until it is done (stated by the PostgreSQL documentation; not run here). Stop the scheduler (standby()) or the nodes first.

Connections: the budget of a cluster

Each node holds a pool, and the pool of a node that is merely idle is already full: with Hikari’s default minimum-idle (equal to the maximum) the measured node held sixteen connections for maximum-pool-size: 16. A three-node cluster with that configuration holds 48 connections of PostgreSQL’s max_connections (100 by default), before the application’s own traffic and before a rolling deployment starts new nodes while the old ones still run. The documented sizing rule, stated on the generic page, is the number of worker threads plus three at least. A test run also measured what a pool really needs when jobs finish instantly: the busy workload of the vacuum section (ten worker threads, instant jobs) ran as many executions in forty seconds with a pool of four connections as with a pool of twelve (1287 and 1286), with no pool timeout, and the number of Quartz sessions seen in pg_stat_activity never exceeded the pool size (12 in the larger run). That shows that Quartz’s transactions are short, not that the rule can be ignored: with jobs that use the same pool as Quartz, or that take a while, the sizing rule is the right default. The production consequences are on Production & Best Practices and the pool itself on Job Stores & Persistence.

Clustering on PostgreSQL

Clustering needs nothing from PostgreSQL beyond what is above: the same schema, the same scheduler name, org.quartz.jobStore.isClustered: "true" and a unique instance id (AUTO). Nodes never talk to each other; the database is the only coordinator, with the row locks of the previous section and the check-in rows of QRTZ_SCHEDULER_STATE.

Three Bookshelf nodes, each a JVM with a QuartzSchedulerThread, three worker threads, a ClusterManager that checks in every 2000 milliseconds and a Hikari pool for the role quartz, all connected by JDBC with currentSchema quartz to one PostgreSQL 17 database named bookshelf. In the database the schema quartz, owned by the role quartz with nothing granted to PUBLIC, holds QRTZ_LOCKS with its two lock rows TRIGGER_ACCESS and STATE_ACCESS that are never updated, QRTZ_SCHEDULER_STATE with one row per live node, QRTZ_TRIGGERS where the update to ACQUIRED from WAITING is the race between nodes, QRTZ_FIRED_TRIGGERS as the churn table whose rows name the owning node, and the remaining tables. A red dashed bracket under the nodes states that no node talks to another node. A red dashed box under the database states that after kill -9 a surviving node locks STATE_ACCESS and TRIGGER_ACCESS, frees the acquired triggers, stores a recovery trigger for each job that requests recovery and deletes the dead node’s rows, and that the job ran again 8.1 to 10.2 seconds after the kill in five runs
Figure 2. Three Bookshelf nodes against one PostgreSQL database
sequenceDiagram participant X as Node X participant DB as PostgreSQL participant Y as Node Y ClusterManager X->>DB: UPDATE QRTZ_SCHEDULER_STATE set LAST_CHECKIN_TIME every check-in interval Note over X,DB: X runs the weekly report: QRTZ_FIRED_TRIGGERS has a row EXECUTING with REQUESTS_RECOVERY true Note over X: kill -9, no shutdown hook, nothing is cleaned up Y->>DB: check-in and SELECT from QRTZ_SCHEDULER_STATE, X is not yet late Note over Y,DB: X is late when LAST_CHECKIN_TIME plus the larger of its interval and Y's own gap plus 7500 ms has passed Y->>DB: BEGIN and lock STATE_ACCESS then TRIGGER_ACCESS Y->>DB: free X's ACQUIRED triggers, store a recovery trigger for the recoverable job Y->>DB: DELETE X's QRTZ_FIRED_TRIGGERS rows and its QRTZ_SCHEDULER_STATE row Y->>DB: COMMIT Y->>DB: acquire and fire the recovery trigger, isRecovering is true
Figure 3. Three nodes, one database, and what a node's death looks like to the others

The failure detection rule is in calcFailedIfAfter: a node is declared failed when its LAST_CHECKIN_TIME plus the larger of its own check-in interval and the time since the observer’s last check-in, plus a fixed 7500 ms, has passed. With clusterCheckinInterval at the default 7500 ms the worst case is therefore about fifteen seconds plus one check-in cycle, up to about 22.5 s; with the 2000 ms of the test below it is about ten. The check-in times are written with each node’s own clock, so the nodes' clocks must agree (the clustering documentation warns about unsynchronized clocks); run a time-sync service on every host.

The recovery has two halves, and they differ in what they promise. A trigger that the dead node had acquired but not started is freed and fires normally. A job that was executing is run again only if it was declared with requestRecovery(true); the recovery trigger carries the original scheduled time and the job sees JobExecutionContext.isRecovering() as true. For everything else the fired-trigger row is cleaned up and the execution is lost. The job must therefore be written to be run twice, which is why the Bookshelf weekly report is the example below. How a cluster shares work (it does not load-balance) and how to design for it is on Clustering.

Testcontainers tests for the Bookshelf jobs

Two tests, both run on 5 October 2026 against postgres:17: a single-node @SpringBootTest that proves the schedule is stored and fires (the PostgreSQL counterpart of the SQL Server test), and a cluster test that starts three JVMs against one container and kills one of them. The Testcontainers dependencies:

Test dependencies (Maven, Boot 4.1 manages the versions)
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers-postgresql</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers-junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.awaitility</groupId>
    <artifactId>awaitility</artifactId>
    <scope>test</scope>
</dependency>

In Testcontainers 2.x the artifact is testcontainers-postgresql and the class org.testcontainers.postgresql.PostgreSQLContainer; the 1.x line used the artifact postgresql and the class org.testcontainers.containers.PostgreSQLContainer.

One node: the schedule is stored and fires

BookshelfPostgresIT.java: the Bookshelf jobs on a real PostgreSQL
package bookshelf;

import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;

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

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.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.jdbc.core.JdbcTemplate;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;

@SpringBootTest(properties = {
        "bookshelf.fast=true",
        "spring.quartz.job-store-type=jdbc",
        "spring.quartz.jdbc.initialize-schema=never",          // Flyway creates the tables from db/migration/V1__quartz_tables.sql
        "spring.quartz.scheduler-name=BookshelfScheduler",
        "spring.quartz.properties.org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.PostgreSQLDelegate"
})
@Testcontainers
class BookshelfPostgresIT {

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

    @Autowired
    Scheduler scheduler;

    @Autowired
    JdbcTemplate jdbc;

    @Test
    void bookshelfJobsAreStoredInPostgresAndFire() throws Exception {
        assertThat(scheduler.getMetaData().getJobStoreClass().getSimpleName()).isEqualTo("LocalDataSourceJobStore");
        assertThat(scheduler.getMetaData().isJobStoreSupportsPersistence()).isTrue();

        // the four Bookshelf beans were stored on startup, in the bytea-backed tables created by Flyway
        assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM QRTZ_JOB_DETAILS", Integer.class)).isEqualTo(4);
        assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM QRTZ_TRIGGERS", Integer.class)).isEqualTo(4);

        // the one-second cache refresh fires and the store records it
        await().atMost(Duration.ofSeconds(30)).untilAsserted(() -> assertThat(jdbc.queryForObject(
                "SELECT COUNT(*) FROM QRTZ_TRIGGERS WHERE TRIGGER_NAME = 'cacheRefreshTrigger' AND PREV_FIRE_TIME > 0",
                Integer.class)).isEqualTo(1));

        // Quartz created its lock rows lazily the first time it needed them
        List<String> locks = jdbc.queryForList("SELECT LOCK_NAME FROM QRTZ_LOCKS", String.class);
        assertThat(locks).contains("TRIGGER_ACCESS");
    }
}

The test passed (1 test, 0 failures, about eight seconds including the container). @ServiceConnection supplied the container’s URL, user and password to the Boot data source, so no @DynamicPropertySource is needed, and Flyway (on the classpath) created the tables from the migration of the Flyway section in the container’s default schema, as the container’s own user. bookshelf.fast=true is a property of the Bookshelf schedule bean that makes the cache refresh fire every second. The wider Testcontainers setup is on Testcontainers Fundamentals and Spring Boot Integration Tests with Testcontainers; testing Quartz jobs in general is on Testing.

Three nodes and a kill -9

A cluster of three schedulers inside one JVM is the easy test to write and the wrong one: Spring registers the data source of a scheduler with Quartz’s static connection manager under the scheduler name, so three schedulers of one name in one JVM overwrite each other’s data source. The test therefore runs each node as its own JVM (bookshelf.BookshelfNode, a main class that starts the Bookshelf application without a web server), which is also what makes kill -9 possible. The container’s init script creates the role and schema of the earlier section:

src/test/resources/db/init-quartz-schema.sql, executed by the container at start
-- Executed by the Testcontainers PostgreSQL container as its superuser, in the database "bookshelf".
CREATE ROLE quartz LOGIN PASSWORD 'change-me-from-a-secret-store';
CREATE SCHEMA quartz AUTHORIZATION quartz;
ALTER ROLE quartz IN DATABASE bookshelf SET search_path = quartz;
REVOKE ALL ON SCHEMA quartz FROM PUBLIC;
BookshelfNode.java: one cluster node as its own JVM (src/main)
package bookshelf;

import org.quartz.JobBuilder;
import org.quartz.Scheduler;
import org.quartz.TriggerBuilder;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;

import java.util.Arrays;
import java.util.Date;

/** One cluster node as its own JVM: {@code java -cp ... bookshelf.BookshelfNode --spring.datasource.url=...}. */
public class BookshelfNode {

    public static void main(String[] args) throws Exception {
        ConfigurableApplicationContext context = new SpringApplicationBuilder(BookshelfApplication.class)
                .web(WebApplicationType.NONE)
                .run(args);
        Scheduler scheduler = context.getBean(Scheduler.class);
        System.out.println("NODE-READY " + scheduler.getSchedulerInstanceId());

        if (Arrays.asList(args).contains("--bookshelf.schedule-weekly-report-now=true")) {
            // the weekly report: long, must not overlap, must be re-run if its node dies
            scheduler.scheduleJob(
                    JobBuilder.newJob(SlowRecoverableJob.class).withIdentity("weeklyReport", "bookshelf")
                            .requestRecovery(true).build(),
                    TriggerBuilder.newTrigger().withIdentity("weeklyReportNow", "bookshelf").startAt(new Date()).build());
        }
        Thread.currentThread().join();
    }
}

The jobs record every execution on the standard output of their JVM, which is how the test sees who ran what:

RecordingJob.java and SlowRecoverableJob.java (src/main; the Bookshelf jobs extend RecordingJob)
public abstract class RecordingJob extends QuartzJobBean {
    @Override
    protected void executeInternal(JobExecutionContext ctx) throws JobExecutionException {
        String node = instanceId(ctx);
        long scheduled = ctx.getScheduledFireTime() == null ? 0 : ctx.getScheduledFireTime().getTime();
        System.out.println("FIRE " + ctx.getJobDetail().getKey().getName() + " node=" + node
                + " scheduled=" + scheduled + " recovering=" + ctx.isRecovering());
        work(ctx);
    }

    private static String instanceId(JobExecutionContext ctx) {
        try { return ctx.getScheduler().getSchedulerInstanceId(); } catch (Exception e) { return "?"; }
    }

    protected void work(JobExecutionContext ctx) throws JobExecutionException { }
}

/** Stand-in for the long weekly statistics report: slow, never overlapping, re-run after a node crash. */
@DisallowConcurrentExecution
public class SlowRecoverableJob extends RecordingJob {
    @Override
    protected void work(JobExecutionContext c) {
        try { Thread.sleep(c.isRecovering() ? 500 : 25_000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
}
PostgresClusterTest.java: three JVM nodes, then a crash
package bookshelf;

import org.junit.jupiter.api.Test;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Predicate;
import java.util.stream.Collectors;

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

@Testcontainers
class PostgresClusterTest {

    static final String QUARTZ_PASSWORD = "change-me-from-a-secret-store"; // same placeholder as db/init-quartz-schema.sql

    @Container
    static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:17")
            .withDatabaseName("bookshelf")
            .withInitScript("db/init-quartz-schema.sql");   // CREATE ROLE quartz, CREATE SCHEMA quartz

    /** A cluster node = a separate JVM running bookshelf.BookshelfNode, so that it can be killed with SIGKILL. */
    static final class Node implements AutoCloseable {
        final Process process;
        final List<String> output = new CopyOnWriteArrayList<>();

        Node(String schedulerName, String... extraArgs) throws Exception {
            List<String> command = new ArrayList<>(List.of(
                    System.getProperty("java.home") + "/bin/java", "-cp", System.getProperty("java.class.path"),
                    "bookshelf.BookshelfNode",
                    "--spring.main.banner-mode=off", "--logging.level.root=WARN",
                    "--logging.level.org.springframework.scheduling.quartz=INFO", // the job store logs under its own class name: shows ClusterManager messages
                    "--bookshelf.fast=true",
                    "--spring.datasource.url=jdbc:postgresql://%s:%d/bookshelf?currentSchema=quartz"
                            .formatted(POSTGRES.getHost(), POSTGRES.getFirstMappedPort()),
                    "--spring.datasource.username=quartz",
                    "--spring.datasource.password=" + QUARTZ_PASSWORD,
                    "--spring.flyway.enabled=true",                       // V1__quartz_tables.sql creates the tables
                    "--spring.quartz.job-store-type=jdbc",
                    "--spring.quartz.jdbc.initialize-schema=never",
                    "--spring.quartz.scheduler-name=" + schedulerName,   // the same name on every node of one cluster
                    "--spring.quartz.properties.org.quartz.scheduler.instanceId=AUTO",
                    "--spring.quartz.properties.org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.PostgreSQLDelegate",
                    "--spring.quartz.properties.org.quartz.jobStore.isClustered=true",
                    "--spring.quartz.properties.org.quartz.jobStore.clusterCheckinInterval=2000",
                    "--spring.quartz.properties.org.quartz.threadPool.threadCount=3"));
            command.addAll(List.of(extraArgs));
            process = new ProcessBuilder(command).redirectErrorStream(true).start();
            Thread reader = new Thread(() -> {
                try (BufferedReader in = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
                    String line;
                    while ((line = in.readLine()) != null) output.add(line);
                } catch (Exception ignored) { }
            });
            reader.setDaemon(true);
            reader.start();
            awaitLine(60_000, l -> l.startsWith("NODE-READY"));
        }

        String awaitLine(long millis, Predicate<String> match) throws InterruptedException {
            long end = System.currentTimeMillis() + millis;
            while (System.currentTimeMillis() < end) {
                for (String l : output) if (match.test(l)) return l;
                Thread.sleep(100);
            }
            throw new AssertionError("no matching output within " + millis + " ms:\n" + String.join("\n", output));
        }

        List<String> fires(String job) {
            return output.stream().filter(l -> l.startsWith("FIRE " + job + " ")).toList();
        }

        @Override public void close() { process.destroyForcibly(); }
    }

    static List<Map<String, Object>> query(String sql) throws Exception {
        try (Connection c = DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
             Statement st = c.createStatement(); ResultSet rs = st.executeQuery(sql)) {
            List<Map<String, Object>> rows = new ArrayList<>();
            while (rs.next()) {
                Map<String, Object> row = new TreeMap<>();
                for (int i = 1; i <= rs.getMetaData().getColumnCount(); i++) row.put(rs.getMetaData().getColumnLabel(i), rs.getObject(i));
                rows.add(row);
            }
            return rows;
        }
    }

    @Test
    void threeNodesShareTheWorkAndNeverRunTheSameFireTimeTwice() throws Exception {
        try (Node a = new Node("share"); Node b = new Node("share"); Node c = new Node("share")) {   // a runs Flyway, b and c find the tables
            Thread.sleep(15_000);

            assertThat(query("SELECT instance_name FROM quartz.qrtz_scheduler_state WHERE sched_name = 'share'")).hasSize(3);

            List<String> fires = new ArrayList<>();
            for (Node n : List.of(a, b, c)) { fires.addAll(n.fires("cacheRefresh")); fires.addAll(n.fires("overdueLoanReminders")); }
            assertThat(fires).isNotEmpty();

            // "FIRE <job> node=<id> scheduled=<epoch ms> recovering=<b>": the job plus its scheduled time must be unique
            Map<String, Long> perFireTime = fires.stream().collect(Collectors.groupingBy(
                    l -> l.split(" ")[1] + " " + l.split(" ")[3], Collectors.counting()));
            assertThat(perFireTime.values()).allMatch(count -> count == 1);

            for (String job : List.of("cacheRefresh", "overdueLoanReminders"))
                System.out.println(job + ": a=" + a.fires(job).size() + " b=" + b.fires(job).size() + " c=" + c.fires(job).size());
        }
    }

    @Test
    void aCrashedNodeIsDetectedAndItsRecoverableJobIsRunAgain() throws Exception {
        try (Node x = new Node("crash", "--bookshelf.schedule-weekly-report-now=true")) {
            x.awaitLine(40_000, l -> l.startsWith("FIRE weeklyReport "));   // x is now inside the 25 s report
            try (Node y = new Node("crash")) {                                // y starts after x took the job
                assertThat(query("SELECT state, requests_recovery FROM quartz.qrtz_fired_triggers WHERE sched_name = 'crash' AND job_name = 'weeklyReport'"))
                        .singleElement().satisfies(row -> {
                            assertThat(row.get("state")).isEqualTo("EXECUTING");
                            assertThat(row.get("requests_recovery")).isEqualTo(true);
                        });

                long killedAt = System.currentTimeMillis();
                x.process.destroyForcibly().waitFor();                       // kill -9: no shutdown hook, no clean-up

                String line = y.awaitLine(60_000, l -> l.startsWith("FIRE weeklyReport ") && l.endsWith("recovering=true"));
                System.out.println("recovered after " + (System.currentTimeMillis() - killedAt) + " ms: " + line);
                y.output.stream().filter(l -> l.contains("ClusterManager")).forEach(System.out::println);
                assertThat(query("SELECT instance_name FROM quartz.qrtz_scheduler_state WHERE sched_name = 'crash'")).hasSize(1);
            }
        }
    }
}

Each node gets its own scheduler name per test (share and crash) so that rows left by a killed node of one test cannot disturb the other, whatever the order the tests run in. DriverManager in the helper uses the container’s own superuser to read the Quartz tables; the nodes use the quartz role. Every JVM started by java.class.path of the test, which works because Surefire runs with a real classpath (useManifestOnlyJar false in the pom of the test project).

Table 12. What the cluster test established (five runs; the output of the last one is quoted)
Check Result

Both tests

Passed in all five runs (the two runs whose time was recorded took 43 and 60 seconds including the container).

Three nodes register

QRTZ_SCHEDULER_STATE held three rows for the scheduler name share, each with CHECKIN_INTERVAL 2000.

No fire time runs twice

In every run the pair job plus scheduled fire time was unique across the three JVMs.

Work is shared, not balanced

Last run: cacheRefresh a=31, b=0, c=0; overdueLoanReminders a=5, b=11, c=0. Other runs: a=28, b=0, c=0 and a=4, b=10, c=0; a=18, b=22, c=0 and a=21, b=0, c=0; a=11, b=20, c=0 and a=16, b=0, c=0. The third node never ran anything in any run: Quartz does not load-balance, the first node to find a due trigger takes it.

The crashed node’s job is run again

QRTZ_FIRED_TRIGGERS showed the weekly report in state EXECUTING with requests_recovery true before the kill. After kill -9 the survivor ran it again, with recovering=true and the original scheduled time, 8087, 10223, 10153, 10010 and 9907 ms after the kill.

What the survivor logged

ClusterManager: detected 1 failed or restarted instances., Scanning for instance "…​"'s failed in-progress jobs., Freed 1 acquired trigger(s)., Deleted 1 complete triggers(s)., Scheduled 1 recoverable job(s) for recovery.

The dead node’s row

Gone: QRTZ_SCHEDULER_STATE held one row (the survivor) afterwards.

The recovery time fits the rule: a check-in every two seconds plus 7.5 seconds is 9.5 seconds, minus the part of the cycle that had already elapsed at the kill, plus the survivor’s own check-in cycle. With the default 7500 ms interval the same arithmetic gives up to about 22.5 s (2 × 7.5 s + 7.5 s); the YAML above chose 5000. The quoted numbers are from one laptop and are a demonstration of the mechanism, not a benchmark. Failover of a node is designed about in detail on Clustering and the observation side (what to alert on) on Observability.

Pitfalls

Table 13. Quartz on PostgreSQL: what goes wrong, why, and the fix
Symptom Cause Fix

PSQLException: Bad value for type long : \xaced0005…​ when the context starts

StdJDBCDelegate reads BYTEA columns with getBlob

org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate

relation "qrtz_job_details" does not exist

Nothing created the tables: Boot’s default initialize-schema is embedded, which means H2, HSQLDB and Derby only

Flyway or Liquibase with initialize-schema: never (or always for a throw-away database)

Every stored job and trigger disappears after a restart

initialize-schema: always re-runs a script that begins with DROP TABLE IF EXISTS

Never use always outside tests; a migration without the DROP lines

The tables appear in public, or in the wrong schema

No currentSchema, no role search_path

currentSchema=quartz in the URL and ALTER ROLE …​ SET search_path

permission denied for table qrtz_job_details with a runtime role

Default privileges were set before the schema was dropped and recreated, or never set for the migration role

ALTER DEFAULT PRIVILEGES FOR ROLE quartz IN SCHEMA quartz GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO quartz_app after every CREATE SCHEMA

Jobs vanish after a restart with two data sources and no error

Two DataSource beans and no @Primary: Boot silently uses the RAM store, even when one of them is @QuartzDataSource

Mark the application’s data source @Primary and the Quartz one @QuartzDataSource (or keep a single DataSource)

A job and its trigger are committed separately, or the lock does not protect an insert

scheduleJob called outside a Spring transaction runs in autocommit; with a second data source the application’s transaction does not include Quartz’s connection

@Transactional around scheduler calls, and a @QuartzTransactionManager when Quartz has its own data source

Nothing fires for a while although the scheduler is running, wait_event = transactionid on a Quartz backend

Another session holds the TRIGGER_ACCESS row lock (a long transaction that locked it, or a manual FOR UPDATE)

Find and end the holder; set idle_in_transaction_session_timeout for application roles

Firing resumes 15 seconds late after a long stall

A short lock_timeout made the lock fail; the scheduler thread retries on dbRetryInterval (15000 ms)

Do not set lock_timeout below the longest stall you accept

txIsolationLevelReadCommitted: "true" slows the scheduler

It adds a SHOW TRANSACTION ISOLATION LEVEL round trip to every Quartz transaction and changes nothing on PostgreSQL

Leave the isolation properties unset

QRTZ_TRIGGERS and QRTZ_FIRED_TRIGGERS grow although autovacuum runs

An old transaction (idle, a long report, a replication slot, hot_standby_feedback) holds the vacuum horizon

Find it in pg_stat_activity and pg_replication_slots; idle_in_transaction_session_timeout; Quartz on its own database

Lookups of a job’s triggers are slow with the bundled Liquibase changelog

liquibase.quartz.init.xml does not create IDX_QRTZ_T_J

Add the index in your own change set

QRTZ_LOCKS is empty after migration

Normal: Quartz inserts the rows on first use

Do nothing; do not insert them yourself

Hikari exhausts max_connections during a rolling deployment

Every idle node holds its whole pool (minimum-idle defaults to the maximum)

Budget nodes times pool size, set minimum-idle, or use a pooler in session mode

Duplicate runs of a job after a crash

The job was re-run on purpose: requestRecovery(true) and the crashed node had it EXECUTING

Make the job idempotent, or leave requestRecovery false

What changed

Table 14. Facts about Quartz on PostgreSQL that changed, or that older articles get wrong
Topic Before Now (Quartz 2.5.2, Boot 4.1.1, pgJDBC 42.7.13, PostgreSQL 17.11)

Boot package of the Quartz and JDBC types

org.springframework.boot.autoconfigure.quartz, org.springframework.boot.autoconfigure.jdbc.DataSourceProperties

org.springframework.boot.quartz.autoconfigure (QuartzDataSource, QuartzTransactionManager) and org.springframework.boot.jdbc.autoconfigure.DataSourceProperties

Migration tool starters

Add flyway-core or liquibase-core

spring-boot-starter-flyway and spring-boot-starter-liquibase; Flyway also needs flyway-database-postgresql since Flyway 10. Boot 3.5 was not run.

Testcontainers coordinates

org.testcontainers:postgresql, org.testcontainers.containers.PostgreSQLContainer

org.testcontainers:testcontainers-postgresql 2.0.5, org.testcontainers.postgresql.PostgreSQLContainer

Terracotta clustering

Documented on the Quartz website

Removed in 2.4.0: PostgreSQL (or another JDBC database) is the clustering path

liquibase.quartz.init.xml

Often described as the way to create the schema

Present in the 2.5.2 jar and identical to tables_postgres.sql in columns and keys, but one index (IDX_QRTZ_T_J) short; an unreleased Liquibase-related primary-key change (#1489) is on main

Which delegate on PostgreSQL

Older tutorials use StdJDBCDelegate, and the delegate list of the Quartz website still names removed classes

PostgreSQLDelegate is the only one that works with the shipped script (measured failure without it)

Locking on acquisition

"`FOR UPDATE` on every trigger acquisition", true before Quartz 1.6.3

Acquisition takes no lock by default; only fire and complete do. acquireTriggersWithinLock brings the old behaviour back, and a batch size above one forces it

Commit validation

n/a

The 2.5.2 source retries a failed COMMIT by checking whether the acquisition or fire really went through (TransactionValidator in acquireNextTriggers and triggersFired); not exercised here

Spring Boot 3.5.16

same Quartz 2.5.2, same pgJDBC 42.7.13

only package names, the migration-tool starters and Spring Framework 6.2.19 differ; not run on 3.5

The full list of version changes and documentation discrepancies of the section is on Migration & What’s Changed.

Where to go next