Quartz on Oracle Database
|
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. |
Oracle Database is one of the databases that Quartz has supported through its JDBC job store from the beginning, and the Quartz jar still contains a delegate class named after it
and two DDL scripts for it. A Spring Boot service that already runs on Oracle can therefore keep its triggers, job definitions and cluster state next to its business data. What makes
Oracle different from the other SQL pages of this section is not the locking (it is plain SELECT … FOR UPDATE, the same statement every other engine receives) but a
driver and script combination: since the Oracle JDBC driver moved to the 23 line, the script that Spring Boot picks for Oracle by default stops working, and the symptom is a
database error on the very first job that is stored. This page explains why, shows the matrix of what was run, and then goes through the setup in the order in which a reader
needs it: dependencies, the delegate, the two scripts, a schema user, migrations, application.yml, locking, clustering and a Testcontainers test. Every statement
about what works or fails was produced by running it.
The generic concepts (what the QRTZ_ tables are, initialize-schema, @QuartzDataSource, pool sizing) are on
Job Stores & Persistence; the reference database page is
Quartz on PostgreSQL; clustering in general is on Clustering. The sibling page with the
same template for another engine that needs script edits is Quartz on SQL Server.
It was written against Quartz 2.5.2, Spring Boot 4.1.1, Spring Framework 7.0.9 and Java 21 and verified on 5 October 2026 on an Apple Silicon (arm64) Mac with the Docker image
gvenzl/oracle-free:slim-faststart, which started natively on arm64 and whose server reported Oracle AI Database 26ai Free Release 23.26.3.0.0 (version 23.26.3.0.0, the
release line that was called Oracle Database 23ai; this page says "23ai" for the BOOLEAN-capable generation and quotes the banner for the exact build) with the pluggable database FREEPDB1.
The JDBC driver was ojdbc11 23.26.3.0.0 (the version Spring Boot 4.1.1 manages), and the matrix was repeated with ojdbc11 21.9.0.0 and 23.7.0.25.01 by overriding the Boot property
oracle-database.version. The other versions in play were Flyway 12.4.0, Liquibase 5.0.3 (both Boot-managed) and Testcontainers 2.0.5. The projects were compiled for Java 21 and
run on JDK 26.0.1. Only Oracle Database Free 23ai was available: a 19c or 21c server was not run, and everything the page says about those releases is labelled as not run. Spring Boot 3.5.16
manages the same Quartz 2.5.2, so everything about Quartz is identical there; only the package of @QuartzDataSource and SchedulerFactoryBeanCustomizer differs
(org.springframework.boot.autoconfigure.quartz instead of org.springframework.boot.quartz.autoconfigure), Spring Framework is 6.2.19, and the Boot-managed driver is
ojdbc11 23.7.0.25.01, which was also run (it fails the same way as 23.26.3.0.0 with the old script, see the matrix).
Status and dependencies
Status: official. The JDBC job store, the OracleDelegate and the two DDL scripts are part of Quartz itself and are maintained with it, unlike the MongoDB and Redis stores.
Quartz’s own tutorial lists Oracle among the databases the JDBC job store "has been used widely with".
| Item | Value measured on this page |
|---|---|
Job store |
Spring’s |
Delegate |
none (Quartz then uses |
Schema script |
|
Service name in the URL |
|
Clustering |
Supported by the JDBC store; measured with two nodes on |
Testcontainers module |
|
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.oracle.database.jdbc</groupId>
<artifactId>ojdbc11</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-oracle-free</artifactId>
<version>2.0.5</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<version>2.0.5</version>
<scope>test</scope>
</dependency>
</dependencies>
Source: Spring Boot: Quartz Scheduler and
Testcontainers: Oracle Free Module. mvn dependency:tree showed ojdbc11 23.26.3.0.0 and Quartz 2.5.2 for this
list; the artifact page of the driver is ojdbc11 on Maven Central.
Two details of that list matter. The driver version is not an incidental choice on Oracle: it decides which schema script works, so check it with mvn dependency:tree instead of assuming
(Boot 4.1.1 manages 23.26.3.0.0, Boot 3.5.16 manages 23.7.0.25.01). And the Testcontainers 2.x artifact is testcontainers-oracle-free with the container class in the package
org.testcontainers.oracle; the 2.0.5 jar contains only that package, no deprecated alias in org.testcontainers.containers.
To pin the driver to an older release, override the Boot property in the POM; this is how the ojdbc11 21.9.0.0 runs on this page were made:
<properties>
<oracle-database.version>21.9.0.0</oracle-database.version>
</properties>
Source: Spring Boot: Dependency Versions. The same override was applied as -Doracle-database.version=21.9.0.0 on the Maven command line for the measurements.
The delegate: what OracleDelegate changes
org.quartz.impl.jdbcjobstore.oracle.OracleDelegate lives in an oracle sub-package, which is the most common way to misspell it: there is no org.quartz.impl.jdbcjobstore.OracleDelegate
class in the jar, and a driverDelegateClass without the sub-package fails with a class-not-found error at startup. Its Javadoc calls it "a driver delegate for the Oracle 10 and 11 database".
It extends StdJDBCDelegate and overrides the code that writes and reads BLOB columns, nothing else:
| Method | What it does differently from StdJDBCDelegate |
|---|---|
|
Inserts or updates the row with |
|
The same three-step write for the trigger’s own |
|
The same for the |
|
Casts the locator to |
|
Reads with |
The class also depends on Oracle’s own oracle.sql.BLOB, so it needs the Oracle driver on the classpath to load, and it is the reason the delegate is tied to Oracle’s driver and not to
any wrapper that hands out another Blob type. The sibling oracle.weblogic.WebLogicOracleDelegate exists for Oracle drivers used inside a WebLogic server; the
Quartz configuration page lists it as org.quartz.impl.jdbcjobstore.oracle.WebLogicOracleDelegate, which is not in the jar (the class is in the oracle.weblogic sub-package).
What matters in practice is that the extra machinery is no longer needed with a current driver. The Bookshelf jobs were run twice on Oracle Database Free with ojdbc11 23.26.3.0.0, once with
no driverDelegateClass (the scheduler then reports no delegate class and Quartz uses StdJDBCDelegate) and once with OracleDelegate, and a second test stored data of hundreds of kilobytes:
| Check | StdJDBCDelegate (default) |
OracleDelegate |
|---|---|---|
Job data |
equal, |
equal, 200306 |
Trigger data |
read back, 50000 bytes |
read back, 50000 bytes |
Calendar ( |
found |
found |
Job replaced with |
10 bytes read back |
10 bytes read back |
Job described with the empty string |
read back as |
read back as |
Source: Quartz documentation: Configure JDBCJobStoreTX. The scenario is a test run (store, read back, compare) against a schema created from tables_oracle23.sql; the last row is an Oracle fact, not a Quartz one: Oracle stores an empty string as NULL.
The recommendation that follows is conservative. Leaving the delegate unset is what was tested most (it is also what Boot does when you set nothing), and OracleDelegate is accepted and worked in
every run where the schema was right. Choose it if you want the vendor-specific BLOB path that the Quartz documentation names for Oracle, and keep an eye on it when you upgrade the driver, because it casts to
a class (oracle.sql.BLOB) that is marked deprecated in the driver; leaving the delegate unset removes that dependency. The setting for the OracleDelegate through Spring Boot is the
spring.quartz.properties key shown in application.yml.
Two schema scripts: tables_oracle.sql and tables_oracle23.sql
The 2.5.2 jar carries two Oracle scripts under org/quartz/impl/jdbcjobstore/, and the 2.3.2 and 2.4.1 jars carry only the first one (read from the jar listings).
They differ in one thing, the type of the boolean flags:
| Column | tables_oracle.sql |
tables_oracle23.sql |
|---|---|---|
|
|
|
|
|
|
|
|
|
Source: tables_oracle23.sql (v2.5.2) and
tables_oracle.sql (v2.5.2); the table is the output of diff on the two files from the jar, plus a trailing blank line that only the old script has.
Everything else is shared: eleven CREATE TABLE statements and twenty CREATE INDEX statements (counted in the file and confirmed with user_tables and user_indexes after a run), VARCHAR2 names of 120 characters
for the scheduler name and 200 for the job and trigger names, NUMBER(13) columns for every time (epoch milliseconds, so the database time zone never matters), BLOB columns for the job data, the trigger data, the
calendars and the blob triggers, and unquoted lower-case identifiers, which Oracle folds to upper case. The script’s comment says Oracle "MUST be created as shared" with job_queue_processes above 2 and then
immediately says other users have run in dedicated mode; the hint dates from the first Quartz releases and it is not needed for the JDBC job store as measured here (the Free image ran in its default mode).
Which script, driver and delegate: the measured matrix
Boot maps the detected database to a script name and uses every id except a handful unchanged: for Oracle that is oracle, so with nothing configured Spring Boot 4.1.1 runs tables_oracle.sql.
That default is the one that breaks. The matrix below was produced by one scenario on every row: start the Bookshelf application on an empty schema with initialize-schema=always, check that the cache refresh and the
nightly reminders fire, schedule a dynamic job with a serialised JobDataMap, restart with initialize-schema=never and check that the stored state survived.
| Script | Delegate | continue-on-error |
ojdbc11 23.26.3.0.0 |
ojdbc11 23.7.0.25.01 |
ojdbc11 21.9.0.0 |
|---|---|---|---|---|---|
|
default |
|
fails at statement 1: |
same |
same |
|
default |
|
fails at statement 1: |
same |
same |
|
default |
|
fails at the first job: |
same |
works |
|
|
|
|
|
works |
|
default |
|
works |
works |
works |
|
|
|
works |
works |
works |
Source: Spring Boot: Quartz Scheduler and the source of StdJDBCDelegate.setBoolean. The matrix is the result of one test run per driver/database row, with the driver version overridden through the oracle-database.version Maven property for the two older drivers; the rows with false fail before any Quartz code runs, see The leading delete and drop statements.
Three conclusions follow from the matrix.
-
The
ORA-12899is a driver behaviour, not a Quartz bug and not an Oracle delegate bug.StdJDBCDelegate.setBooleancallsPreparedStatement.setBoolean, and the 23.x driver sends a four-character value for it (the error says "actual: 4"), which does not fit into aVARCHAR2(1)column. The 21.9 driver stores1and0instead (the stock delegate left1and0in theVARCHAR2(1)columns when it was run, and theBOOLEANcolumns oftables_oracle23.sqlalso accept it).OracleDelegatedoes not change this, because it calls the samesetBoolean. -
tables_oracle23.sqlworked with all three drivers on the 23ai server, so on a 23ai database it is the script to use whatever the driver, and it removes the dependency on the driver’s boolean binding. -
The
BOOLEANSQL column type exists from Oracle Database 23ai. A 19c or 21c server cannot createtables_oracle23.sql; that was not run here because no such server was available. For those releases the two options below keepVARCHAR2(1)columns and make the driver store1and0.
Choosing the script in Spring Boot
Boot’s spring.quartz.jdbc.schema property names the script, and spring.quartz.jdbc.platform is the value that replaces @@platform@@ in its default path. Both ways of selecting the 23 script were run and
created the tables:
spring:
quartz:
jdbc:
# either name the script ...
schema: classpath:org/quartz/impl/jdbcjobstore/tables_oracle23.sql
# ... or change the platform id that Boot substitutes into tables_@@platform@@.sql
# platform: oracle23
Source: Spring Boot: Quartz Scheduler. Each variant was run alone (schema, and platform: oracle23 with no schema), with initialize-schema=always, and both passed the Bookshelf scenario with ojdbc11 23.26.3.0.0.
Keeping tables_oracle.sql: pin the driver or bind the flags yourself
If the server is older than 23ai the VARCHAR2(1) script is the only option, and with a 23.x driver the flags must be bound as text. Option A is to pin ojdbc11 to a 21.x release (the property override shown above); with
ojdbc11 21.9.0.0 and tables_oracle.sql the stock delegate and OracleDelegate both worked, against the 23ai server. Option B keeps the 23.x driver and overrides the two protected methods that StdJDBCDelegate uses for
booleans (setBoolean for writing, and the two getBoolean overloads for reading):
package bookshelf;
import org.quartz.impl.jdbcjobstore.oracle.OracleDelegate;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public class Varchar2FlagOracleDelegate extends OracleDelegate {
@Override
protected void setBoolean(PreparedStatement ps, int index, boolean val) throws SQLException {
ps.setString(index, val ? "1" : "0");
}
@Override
protected boolean getBoolean(ResultSet rs, String columnName) throws SQLException {
return "1".equals(rs.getString(columnName));
}
@Override
protected boolean getBoolean(ResultSet rs, int columnIndex) throws SQLException {
return "1".equals(rs.getString(columnIndex));
}
}
Source: Quartz Javadoc: StdJDBCDelegate and
StdJDBCDelegate.java (v2.5.2). The class compiled and was run as driverDelegateClass against tables_oracle.sql (without its leading statements) with ojdbc11 23.26.3.0.0 and with 21.9.0.0 on the 23ai server: the Bookshelf scenario passed and the
IS_DURABLE column held 1 for the four stored jobs. It was not run on a 19c or 21c server, so test it there before relying on it.
The leading delete and drop statements
Every Oracle script starts with eleven delete from qrtz_… statements and eleven drop table qrtz_… statements, with no IF EXISTS, before the first CREATE TABLE. On a schema that has no Quartz
tables yet each of them fails with ORA-00942 (table or view does not exist). That has three consequences, each measured:
-
With Boot’s default
continue-on-error: truethe initializer logs the failures, skips them and runs theCREATEstatements, so the first start works. The failure is silent, which is how theORA-12899of the previous section is the first error a reader sees. -
With
continue-on-error: falsestartup fails at statement 1, whichever script and driver (the first two rows of the matrix). -
With
initialize-schema: alwaysthe head also does its job on an existing schema, and that is a data-loss trap: the application was started with the development profile of this page, stored jobs, restarted, and therunscounter that the nightly job keeps in itsJobDataMaphad gone from 4 back to 1, because the second start deleted and recreated every table. Never usealwaysagainst a database whose triggers matter.
A migration tool must not receive those lines: Flyway and Liquibase stop at the first failing statement, and a migration is supposed to run exactly once on an empty schema. The edit is to delete them and keep
every CREATE:
unzip -p quartz-2.5.2.jar org/quartz/impl/jdbcjobstore/tables_oracle23.sql \
| sed -e '/^delete from /d' -e '/^drop table /d' > V1__quartz_tables.sql
Source: tables_oracle23.sql (v2.5.2). The result has 11 CREATE TABLE and 20 CREATE INDEX statements and is byte-identical to the file used in every Flyway, Liquibase and strict (continue-on-error: false) run below.
The same sed on tables_oracle.sql gives the VARCHAR2 variant. Both were run through Boot’s initializer with continue-on-error: false as the schema user QUARTZ described next, and the
Bookshelf scenario passed with the 23 script on ojdbc11 23.26.3.0.0, and with the old script on ojdbc11 21.9.0.0 (and failed with ORA-12899 on 23.26.3.0.0, as the matrix says).
A schema user on a pluggable database
Quartz needs a schema of its own, or at least its own set of tables: a user that owns the eleven tables is the simplest arrangement. The Free image is a container database whose application data lives in the pluggable
database FREEPDB1, so the connection uses that service name, not the container root. The commands were run as SYSTEM against FREEPDB1:
CREATE USER quartz IDENTIFIED BY "<choose-a-strong-password>"
DEFAULT TABLESPACE users
TEMPORARY TABLESPACE temp
QUOTA UNLIMITED ON users;
GRANT CREATE SESSION, CREATE TABLE TO quartz;
Source: Oracle Database SQL Language Reference: CREATE USER. Executed through JDBC against jdbc:oracle:thin:@//localhost:<mapped-port>/FREEPDB1 as SYSTEM; a test password that is not shown here was used. Querying session_privs as the new user returned exactly CREATE SESSION and CREATE TABLE.
The user created that way ran the whole Bookshelf scenario: Boot’s initializer created the eleven tables and the twenty indexes in the QUARTZ schema, Quartz fired the jobs and stored a serialised JobDataMap, and Flyway and Liquibase (next sections) used the same
user to create their own bookkeeping tables. No privilege was granted on any other schema and no role was needed. The quota is what lets the user store the table, index and BLOB segments; the CREATE TABLE
privilege also covers the indexes the owner creates in its own schema. In production, run the creation script as an administrator once, keep the password in a secret store, and give the application only
${DB_PASSWORD} (see application.yml).
The convenient alternative in tests is to let the Testcontainers module create the user: new OracleContainer(image).withUsername("quartz").withPassword("…") makes the image create an application user with that name, and
querying it showed far more than Quartz needs: 29 session privileges (including CREATE TABLE, CREATE VIEW, CREATE PROCEDURE, CREATE JOB and CREATE MATERIALIZED VIEW) and the roles DB_DEVELOPER_ROLE, RESOURCE and SODA_APP. That is fine for a
throw-away container and wrong as a model for a real database.
Two Oracle facts worth keeping next to the setup. If the Quartz tables must live in another schema than the connecting user’s, Quartz substitutes org.quartz.jobStore.tablePrefix verbatim into every statement (the default is QRTZ_), so a
schema-qualified prefix such as QUARTZ.QRTZ_ is the mechanism; that variant was not run here. And because Oracle treats an empty string as NULL, a job described with "" reads back with a null description (measured above), which is harmless but shows up in comparisons.
Creating the tables: three ways
Boot’s initializer, for development and tests
Boot’s initializer is enough for a laptop or a test, provided it uses the right script and always is understood to destroy the data. The profile below was run exactly as shown (with the container’s mapped port in place of
localhost:1521, and the password supplied as the system property DB_PASSWORD):
spring:
datasource:
url: jdbc:oracle:thin:@//localhost:1521/FREEPDB1
username: quartz
password: ${DB_PASSWORD}
quartz:
job-store-type: jdbc
jdbc:
initialize-schema: always # development and tests only: the script drops the tables first
schema: classpath:org/quartz/impl/jdbcjobstore/tables_oracle23.sql
continue-on-error: true # the default, needed because the script's first statements fail on an empty schema
Source: Spring Boot: Quartz Scheduler. Run with ojdbc11 23.26.3.0.0: the jobs fired. The restart check of the harness then failed, as it should: the second start ran the script again and the stored counter went back from 4 to 1, which is the always behaviour described in The leading delete and drop statements.
Flyway
In production the schema should be created by a migration tool, run once and recorded. With Flyway the production edit is the sed of the previous section and a file name that follows Flyway’s convention. Flyway
ships database support as separate modules since version 10, so Oracle needs flyway-database-oracle next to the Boot starter; both are managed by Boot 4.1.1 (12.4.0 was run).
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-oracle</artifactId>
</dependency>
Source: Spring Boot: Using Flyway and Flyway: Oracle.
-- the 11 CREATE TABLE and 20 CREATE INDEX statements of tables_oracle23.sql,
-- without its leading "delete from" and "drop table" statements
CREATE TABLE qrtz_job_details
(
SCHED_NAME VARCHAR2(120) NOT NULL,
JOB_NAME VARCHAR2(200) NOT NULL,
JOB_GROUP VARCHAR2(200) NOT NULL,
DESCRIPTION VARCHAR2(250) NULL,
JOB_CLASS_NAME VARCHAR2(250) NOT NULL,
IS_DURABLE BOOLEAN NOT NULL,
IS_NONCONCURRENT BOOLEAN NOT NULL,
IS_UPDATE_DATA BOOLEAN NOT NULL,
REQUESTS_RECOVERY BOOLEAN NOT NULL,
JOB_DATA BLOB NULL,
CONSTRAINT QRTZ_JOB_DETAILS_PK PRIMARY KEY (SCHED_NAME,JOB_NAME,JOB_GROUP)
);
-- ... the other ten tables and the indexes, exactly as in the jar script
Source: tables_oracle23.sql (v2.5.2). Only the first table is shown; the file that Flyway ran is the complete output of the sed command above.
spring:
datasource:
url: jdbc:oracle:thin:@//localhost:1521/FREEPDB1
username: quartz
password: ${DB_PASSWORD}
quartz:
job-store-type: jdbc
scheduler-name: bookshelf
jdbc:
initialize-schema: never # the schema is owned by a migration tool, see below
properties:
org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.oracle.OracleDelegate
org.quartz.jobStore.misfireThreshold: "60000"
org.quartz.threadPool.threadCount: "10"
flyway:
locations: classpath:db/migration/oracle
Source: Spring Boot: Database Initialization and Spring Boot: Quartz Scheduler. This exact file was run with only Flyway on the classpath, with the container’s URL in place of localhost:1521: Flyway applied V1__quartz_tables.sql, Quartz started afterwards, the Bookshelf jobs fired and the stored counter survived a restart (5 to 6). In the equivalent run that queried it, flyway_schema_history held one row for version 1 with success set.
Boot orders the scheduler after the database initializers, so Flyway runs before Quartz reads its tables. The schema then contained 12 tables, the 11 Quartz tables plus flyway_schema_history (Flyway quotes its name in lower case on Oracle).
Do not edit the migration after it was applied; a change to the Quartz tables, for example when upgrading Quartz, is a new V2__ migration. The general workflow is on
Schema Evolution and the choice between tools on Choosing a Migration Tool.
Liquibase
Liquibase reads the same edited script through sqlFile. The changelog below, with the script next to it, created the 11 Quartz tables plus Liquibase’s DATABASECHANGELOG and DATABASECHANGELOGLOCK (13 tables in all) and the
Bookshelf jobs ran afterwards.
databaseChangeLog:
- changeSet:
id: quartz-tables-oracle23
author: bookshelf
changes:
- sqlFile:
path: db/changelog/oracle/quartz/tables_oracle23.sql
splitStatements: true
endDelimiter: ;
stripComments: true
Source: Liquibase: sqlFile and Spring Boot: Using Liquibase. The file db/changelog/oracle/quartz/tables_oracle23.sql is the output of the sed command above; the changelog ran under Liquibase 5.0.3.
spring:
datasource:
url: jdbc:oracle:thin:@//localhost:1521/FREEPDB1
username: quartz
password: ${DB_PASSWORD}
quartz:
job-store-type: jdbc
scheduler-name: bookshelf
jdbc:
initialize-schema: never # the schema is owned by a migration tool
liquibase:
change-log: classpath:db/changelog/oracle/db.changelog-master.yaml
Source: Spring Boot: Database Initialization. Run exactly as shown (container URL substituted) with only Liquibase on the classpath; the Bookshelf jobs fired and the stored counter survived a restart (6 to 7).
The Quartz jar also contains a database-neutral changelog, org/quartz/impl/jdbcjobstore/liquibase.quartz.init.xml (it is in the Quartz jar, not in Spring Boot): one changeset, quartz-init, that creates the same 11 tables
with a blob_type property that is BYTEA on PostgreSQL and BLOB elsewhere. It can be included from a master changelog with file: org/quartz/impl/jdbcjobstore/liquibase.quartz.init.xml, and it was run on the 23ai server with
ojdbc11 23.26.3.0.0 and the default delegate: it works, and the Oracle catalogue afterwards showed BOOLEAN for IS_DURABLE and BOOL_PROP_1 and BLOB for JOB_DATA, CALENDAR and BLOB_DATA. That is the same
column shape as tables_oracle23.sql, so on a 23ai database it is the shortest route. How Liquibase maps its BOOLEAN type on an older Oracle release was not run here. Choose the bundled file when you want one changelog for every
database in the fleet, and the Oracle script when you want exactly the DDL that the Oracle page of Quartz ships; do not mix them in one environment. The general Liquibase workflow is on
Liquibase: Getting Started.
Source: liquibase.quartz.init.xml (v2.5.2). The column types were read from user_tab_columns of a schema created by this file.
application.yml
The Flyway profile above is the shape of a production configuration; this is the same configuration with the variants spelled out, in the form that a deployment would use, with every secret and host name coming from the environment:
spring:
datasource:
url: jdbc:oracle:thin:@//${DB_HOST}:1521/${DB_SERVICE} # for the Free image: FREEPDB1
username: quartz
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 13 # worker threads + 3, see Production & Best Practices
quartz:
job-store-type: jdbc
scheduler-name: bookshelf
jdbc:
initialize-schema: never
properties:
org.quartz.threadPool.threadCount: "10"
org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.oracle.OracleDelegate # optional
Source: Spring Boot: Quartz Scheduler. The keys are the ones of the Flyway profile that was run; the hikari line and the URL placeholders are the only additions, and the pool value follows the rule of Job Stores & Persistence.
A few remarks. The URL uses the service-name form @//host:port/service; the older host:port:SID form names an instance and is the wrong choice for a pluggable database (that form was not run). No driver-class-name is
needed, Boot infers oracle.jdbc.OracleDriver from the URL. org.quartz.jobStore.misfireThreshold defaults to 60000 ms on a JDBC store (read from JobStoreSupport), so the line in the Flyway profile only restates the default. And when a second
DataSource exists in the application, mark one @Primary (and the Quartz one @QuartzDataSource when it is not the primary); with no @Primary Boot silently falls back to the RAM store, even if one data source is @QuartzDataSource, as explained on the job stores page.
The Bookshelf jobs on Oracle
The five kinds of work of the Bookshelf scenario ran unchanged on Oracle: the cache refresh (a SimpleTrigger), the nightly overdue-loan reminders, the weekly statistics and the monthly retention cleanup (three
CronTrigger`s), and a per-member reminder scheduled at runtime with a serialised `JobDataMap. After a run the tables held four jobs and four triggers (three cron, one simple), the dynamic job fired with its data, and after a restart with
initialize-schema=never the @PersistJobDataAfterExecution counter of the overdue job carried on (5 to 6 in the Flyway run). While the scheduler is running these queries show what Quartz keeps; the epoch-millisecond columns are converted
with Oracle’s NUMTODSINTERVAL:
SELECT trigger_name, trigger_state, trigger_type,
TO_CHAR(TIMESTAMP '1970-01-01 00:00:00 +00:00' + NUMTODSINTERVAL(next_fire_time / 1000, 'SECOND'),
'YYYY-MM-DD HH24:MI:SS TZR') AS next_fire
FROM qrtz_triggers ORDER BY next_fire_time;
SELECT job_name, job_group, is_durable, is_nonconcurrent, requests_recovery,
DBMS_LOB.GETLENGTH(job_data) AS job_data_bytes
FROM qrtz_job_details ORDER BY job_name;
SELECT instance_name, checkin_interval FROM qrtz_scheduler_state;
SELECT lock_name FROM qrtz_locks ORDER BY lock_name;
Source: Quartz Tutorial: Lesson 9, JobStores. The statements were run against the schema of a running clustered scheduler.
qrtz_triggers: name |
state | type | next fire (UTC) |
|---|---|---|---|
|
|
|
5 minutes after the previous run |
|
|
|
2026-10-06 00:00:00 (02:00 in the JVM’s zone, Europe/Madrid, UTC+2 in October) |
|
|
|
2026-10-12 04:00:00 (06:00 local on Monday) |
|
|
|
2026-11-01 02:30:00 (03:30 local, UTC+1 after the clock change) |
Source: Quartz Tutorial: Lesson 9, JobStores. The job table showed is_durable as true for all four jobs, is_nonconcurrent as true only for overdueLoanReminders, a JOB_DATA of 273 bytes (357 for the job with a counter), one QRTZ_SCHEDULER_STATE row with checkin_interval 2000 and 532 ms since its last check-in, and the two lock rows. The cron triggers in this run used the default time zone of the JVM, Europe/Madrid, which is why the UTC fire times are offset from the cron fields; a production trigger should carry an explicit TimeZone.
Transactions and locking
Under Spring Boot the job store is LocalDataSourceJobStore, a JobStoreCMT subclass, and JobStoreCMT.initialize forces database locks on unless the data source is HSQL (the Spring class switches to an in-memory semaphore only for HSQL). So on Oracle every
Quartz scheduler uses StdRowLockSemaphore, even a single non-clustered node, and a clustered one needs it by definition. The semaphore takes a row lock with exactly this statement (the constant SELECT_FOR_LOCK, with the table prefix and scheduler name filled in):
SELECT * FROM QRTZ_LOCKS WHERE SCHED_NAME = 'BookshelfCluster' AND LOCK_NAME = :1 FOR UPDATE
Source: StdRowLockSemaphore.java (v2.5.2) and Oracle Database SQL Language Reference: SELECT, the FOR UPDATE clause. The statement text and its execution count (9 in an eight second run with a two second check-in) come from V$SQL; the same view also showed the INSERT INTO QRTZ_LOCKS(SCHED_NAME, LOCK_NAME) VALUES (…) that creates a lock row on first use (twice, once for STATE_ACCESS and once for TRIGGER_ACCESS; the script inserts none).
That is plain Oracle SQL, so no selectWithLockSQL override is needed (unlike SQL Server, where Quartz substitutes lock hints). The two lock names matter: TRIGGER_ACCESS guards every change of trigger state that must not interleave between nodes, and STATE_ACCESS guards the cluster check-in and the
recovery of a failed node. By default acquireNextTriggers does not take TRIGGER_ACCESS at all (acquireTriggersWithinLock is false and the batch size is 1): it moves a trigger from WAITING to ACQUIRED with a conditional UPDATE and checks that one row changed,
and only the following triggersFired call, which turns the acquired trigger into a fired one, runs inside the lock.
Source: JobStoreSupport.java (v2.5.2), methods acquireNextTriggers and triggersFired. The two statements are drawn from the source; the lock behaviour of the row itself is the measurement below.
Oracle’s behaviour on that row was measured with two JDBC sessions on a schema with a clustered scheduler stopped in standby: the first session ran the statement above and kept its transaction open, and the second session tried to lock the same row:
| Second session’s statement | Result |
|---|---|
|
blocked: still waiting after 2000 ms; it returned |
|
|
|
|
|
returned no rows after 4 to 7 ms |
Source: Oracle Database Concepts: Data Concurrency and Consistency. The measurements are from a test run with two connections to QRTZ_LOCKS.
Two practical consequences. A session that runs SELECT … FOR UPDATE on QRTZ_LOCKS for inspection and is left open in a SQL client freezes the firing of every node until it ends: use plain SELECT statements (as in the queries above, which Oracle answers
without locking, because readers do not block writers). And Oracle’s undo-based concurrency means the tables do not need a VACUUM-style routine for the constant insert and delete churn of QRTZ_FIRED_TRIGGERS (the PostgreSQL page has to deal with bloat there).
That second point is Oracle’s documented behaviour and was not measured.
The isolation level is the Oracle default. A connection from the Hikari pool reported Connection.TRANSACTION_READ_COMMITTED (2) with auto-commit on; LocalDataSourceJobStore sets dontSetAutoCommitFalse because Spring manages the transaction around the Quartz calls
(see Transaction Isolation & Locking for the Spring side). Quartz has an opt-in org.quartz.jobStore.txIsolationLevelSerializable (default false in JobStoreSupport); the Oracle documentation says that a serializable transaction raises
an error instead of waiting when it meets a concurrent change, so leave it off on Oracle. That behaviour is documented, not measured here. According to the Oracle documentation the database offers only READ COMMITTED, SERIALIZABLE and read-only transactions, which is why the Quartz option offers no other level.
If a lock attempt fails, StdRowLockSemaphore retries up to 3 times one second apart before it throws (maxRetry and retryPeriod, read from the source).
Clustering
Clustering uses the same tables and the same row locks; the setup is the Boot properties of Clustering plus the Oracle ones of this page. It was run with two schedulers in one JVM (same scheduler name, same schema, instanceId AUTO,
isClustered=true, clusterCheckinInterval=2000, three worker threads each), on tables_oracle23.sql, the default delegate and ojdbc11 23.26.3.0.0, with the first node creating the schema (always, continue-on-error at its default) and the second using never:
| Job | Distinct scheduled fire times | Executions | Scheduled times executed more than once |
|---|---|---|---|
|
14 |
14 (all on one node) |
0 |
|
7 |
7 (2 on the first node, 5 on the second) |
0 |
Source: Quartz documentation: Configure Clustering with JDBC-JobStore. The numbers are from one run of a two-node test; Quartz does not balance load, a free node takes whatever it acquires first, so the split between nodes varies from run to run.
No scheduled time ran twice and both nodes reported clustered=true. The failure-detection rules (a node is declared dead after its last check-in plus the larger of the check-in interval and its own check-in delay, plus 7500 ms),
the kill-and-recover behaviour of requestsRecovery and the three-node case are measured on PostgreSQL and described on Clustering; they are engine-independent and were not repeated on Oracle. Oracle RAC (several database instances)
was not available and was not tested. The cluster uses the JVM clock of each node for check-ins, not the database clock, so keep the nodes' clocks synchronised.
A Testcontainers test for the Bookshelf jobs
The test below starts Oracle Database Free in a container, lets Boot create the schema from the headless tables_oracle23.sql with continue-on-error=false (so any script error fails the test; the property exists on Spring Boot 4.x only, on 3.5.16 the initializer hard-codes true), and checks that the Bookshelf triggers are rows in Oracle and that the cache refresh fired
and persisted its previous fire time. The image is large (4.81 GB) but started natively on the Apple Silicon Mac that ran the tests: the container was ready in 6 to 9 seconds and the whole test class took 17 seconds.
package bookshelf;
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.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.oracle.OracleContainer;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
@SpringBootTest(classes = BookshelfApplication.class, properties = {
"spring.quartz.job-store-type=jdbc",
"spring.quartz.jdbc.initialize-schema=always",
"spring.quartz.jdbc.continue-on-error=false",
"spring.quartz.jdbc.schema=classpath:db/quartz/tables_oracle23_nohead.sql",
"spring.quartz.scheduler-name=bookshelf"
})
@Testcontainers
class BookshelfOracleTest {
@Container
static final OracleContainer ORACLE = new OracleContainer("gvenzl/oracle-free:slim-faststart")
.withUsername("quartz")
.withPassword("quartz-test-only");
@DynamicPropertySource
static void datasource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", ORACLE::getJdbcUrl);
registry.add("spring.datasource.username", ORACLE::getUsername);
registry.add("spring.datasource.password", ORACLE::getPassword);
}
@Autowired Scheduler scheduler;
@Autowired JdbcTemplate jdbc;
@Test
void storesTheBookshelfJobsInOracleAndFiresThem() throws Exception {
assertThat(scheduler.getMetaData().isJobStoreSupportsPersistence()).isTrue();
assertThat(scheduler.getMetaData().getJobStoreClass().getSimpleName()).isEqualTo("LocalDataSourceJobStore");
// the schema script created the 11 QRTZ_ tables in the QUARTZ schema
assertThat(jdbc.queryForObject(
"SELECT COUNT(*) FROM user_tables WHERE table_name LIKE 'QRTZ\\_%' ESCAPE '\\'", Integer.class)).isEqualTo(11);
// the Bookshelf triggers are rows, not memory
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM qrtz_triggers", Integer.class)).isEqualTo(4);
// the cache refresh fired at least once and its previous fire time was persisted
await().atMost(Duration.ofSeconds(30)).untilAsserted(() ->
assertThat(jdbc.queryForObject(
"SELECT prev_fire_time FROM qrtz_triggers WHERE trigger_name = 'cacheRefreshTrigger'", Long.class))
.isGreaterThan(0L));
// flags are real booleans in the 23ai script
assertThat(jdbc.queryForObject(
"SELECT COUNT(*) FROM qrtz_job_details WHERE is_durable = TRUE", Integer.class)).isEqualTo(4);
}
}
Source: Testcontainers: Oracle Free Module and Spring Boot: Quartz Scheduler. This class was compiled and run (1 test, 0 failures) in a test run, with src/test/resources/db/quartz/tables_oracle23_nohead.sql being the output of the sed command of The leading delete and drop statements. Its BookshelfApplication and the five Bookshelf jobs are the ones of Getting Started with Spring Boot. The password is a test value that exists only inside the throw-away container.
What was run, precisely: the gvenzl/oracle-free:slim-faststart image (Oracle Database Free, banner Oracle AI Database 26ai Free Release 23.26.3.0.0), testcontainers-oracle-free 2.0.5, ojdbc11 23.26.3.0.0 for this class, and
ojdbc11 21.9.0.0 and 23.7.0.25.01 for the matrix scenarios. What matters on Oracle is the user: withUsername creates a broad
application user (see A schema user on a pluggable database), so a test that wants to prove that a least-privilege user is enough must create the user itself, as the tests behind this page did with CREATE USER and the two grants. The general technique is on
Spring Boot Integration Tests with Testcontainers and the full Quartz testing story on Testing.
Pitfalls
| Pitfall | What to do |
|---|---|
Running the default Boot configuration against Oracle with a 23.x driver |
Boot’s default script for Oracle is |
Expecting |
It calls the same |
Misspelling the delegate |
The class is |
The |
Silent while |
|
The script deletes and drops the tables first, so every restart wipes jobs, triggers and counters (a |
Reading the |
The cause is the driver’s |
|
|
Connecting with the wrong service name |
Use the service of the pluggable database in the form |
Giving the application the powerful user of a test container |
|
Leaving |
The row lock blocks every node’s |
Enabling |
Oracle’s serializable mode raises an error on concurrent changes instead of waiting (Oracle documentation, not measured here). The default |
Comparing descriptions with |
Oracle stores the empty string as |
Writing directly to the |
Quartz keeps cross-table invariants (trigger state, fired triggers, lock rows) that a manual |
|
|
Assuming the Free image tells you its version |
The image has no version label; query |
What changed
-
Quartz 2.5.2’s jar adds
tables_oracle23.sql(BOOLEAN flags, Oracle 23ai). The jars of Quartz 2.3.2 and 2.4.1 contain onlytables_oracle.sql;OracleDelegateandWebLogicOracleDelegateare in all three. The jar grows from 22 DDL scripts to 25 overall, as listed on the job stores page. -
Spring Boot 4.1.1 manages
ojdbc1123.26.3.0.0; Boot 3.5.16 manages 23.7.0.25.01. Under Boot 4.1.1 both versions fail ontables_oracle.sql(measured by overridingoracle-database.version); the Boot 3.5.16 build itself was not run. The Boot 3.5 property names, includingspring.quartz.jdbc.*, are unchanged in Boot 4; the types moved toorg.springframework.boot.quartz.autoconfigure. -
Boot still maps Oracle to the unchanged platform id
oracle, so the default script is the old one; it does not select the 23 script by itself. -
Testcontainers 2.x moved the Oracle module to
testcontainers-oracle-freeandorg.testcontainers.oracle.OracleContainer. -
The Quartz configuration page still lists a
WebLogicOracleDelegatein theoraclepackage, which the 2.5.2 jar does not contain; the class is inoracle.weblogic. This is collected with the other documentation discrepancies on Migration & What’s Changed.
Where to go next
-
Job Stores & Persistence: the support matrix and the generic schema management that every database page builds on.
-
Clustering: the failure detection, recovery and the tuning of check-in intervals, on the reference database.
-
Production & Best Practices: pool sizing, monitoring and the rules for never touching the
QRTZ_tables by hand. -
Quartz on PostgreSQL and Quartz on SQL Server: the other engines with a delegate or script requirement.