Quartz.NETQuartz.NET
Home
Features
Blog
Discussions
NuGet
GitHub
Home
Features
Blog
Discussions
NuGet
GitHub
  • Getting Started

    • Overview
    • Quartz 4 Quick Start
    • Tutorial
      • Using Quartz
      • Jobs And Triggers
      • More About Jobs & JobDetails
      • Job Data
      • More About Triggers
      • Querying Jobs and Triggers
      • Simple Triggers
      • Cron Triggers
      • RecurrenceTrigger
      • Time and TimeProvider
      • Trigger and Job Listeners
      • Scheduler Listeners
      • Job Execution Middleware
      • Job Stores
      • Configuration, Resource Usage and Building a Scheduler
      • Building a Scheduler Without a Host
      • Clustering
      • Execution Groups
      • Node Affinity (Preferred Node)
      • Testing
      • Compile-Time Checks
      • Declaring Jobs with Attributes
      • Delegate Jobs
    • Configuration Reference
    • JSON Configuration
    • Cron Expression Reference
    • Multi-Tenancy
    • Comparison
    • Frequently Asked Questions
    • Best Practices
    • Before You Go Live
    • Operating a Cluster
    • Log Events
    • Tenancy Patterns
    • Database Schema
    • Database Schema Changes
    • Migration Guide
    • Troubleshooting
    • API Documentation
  • How To's
    • One-Off Job
    • Rescheduling Jobs
    • Backfill
    • Retrying Failed Jobs
    • Pausing with a Reason
    • Job Continuations
    • Overlap Policy
    • Progress and Execution Logs
    • Job Outcomes
    • Multiple Triggers
    • Job Template
    • Running Quartz under Aspire
    • Quartz.NET with Wolverine
    • Coming from Hangfire
    • Coming from TickerQ
    • Embedding Quartz in a Library
    • Running under an External Leader Election
    • Publishing Trimmed and Native AOT
    • Extending Quartz: what is open, what is closed, and how to ask
    • A Job Store of Your Own
    • A Driver Delegate for a New Database
    • Persisting a Custom Trigger Type
    • A Lock Handler of Your Own
  • Packages

    • Quartz Core Additions

      • Jobs
      • Serialization (System.Text.Json)
      • JSON Serialization
      • Plugins
    • Integrations

      • Aspire Integration
      • ASP.NET Core Integration
      • HTTP API
      • HTTP Client
      • Dashboard
      • Dashboard Agent
      • Hosted Services Integration
      • Microsoft DI Integration
      • Multiple Schedulers with Microsoft DI
      • Observability
      • Redis Lock Handler
      • TimeZoneConverter Integration
      • Weasel Schema Management
    • 3rd Party Plugins for Quartz
  • Quartz 3.x

    • Getting Started

      • Quartz 3 Quick Start
      • Tutorial
        • Using Quartz
        • Library Overview
        • Jobs And Triggers
        • More About Jobs
        • More About Triggers
        • Execution Groups
        • Node Affinity (Preferred Node)
        • Simple Triggers
        • Cron Triggers
        • RecurrenceTrigger
        • Trigger and Job Listeners
        • Scheduler Listeners
        • Job Stores
        • Tuning the Scheduler
        • Configuration, Resource Usage and SchedulerFactory
        • Advanced (Enterprise) Features
      • Configuration Reference
      • JSON Configuration
      • Multi-Tenancy
      • Frequently Asked Questions
      • Best Practices
      • Tenancy Patterns
      • Troubleshooting
      • API Documentation
      • Database Schema
      • Database Schema Changes
      • Migration Guide
      • Miscellaneous Features
    • How To's

      • One-Off Job
      • Multiple Triggers
      • Job Template
      • Using the CronTrigger
      • Rescheduling Jobs
    • Packages

      • Quartz Core Additions

        • Dashboard
        • Jobs
        • Serialization (System.Text.Json)
        • Serialization (Newtonsoft Json.NET)
        • Plugins
      • Integrations

        • ASP.NET Core Integration
        • Hosted Services Integration
        • Microsoft DI Integration
        • Multiple Schedulers with Microsoft DI
        • OpenTelemetry Integration
        • OpenTracing Integration
        • Redis Lock Handler
        • TimeZoneConverter Integration
      • 3rd Party Plugins for Quartz
  • Old Releases

    • Quartz 2.x
      • Quartz 2 Quick Start
      • Tutorial
        • Lesson 1: Using Quartz
        • Lesson 2: Jobs And Triggers
        • Lesson 3: More About Jobs & JobDetails
        • Lesson 4: More About Triggers
        • Lesson 5: SimpleTrigger
        • Lesson 6: CronTrigger
        • Lesson 7: TriggerListeners and JobListeners
        • Lesson 8: SchedulerListeners
        • Lesson 9: JobStores
        • Lesson 10: Configuration, Resource Usage and SchedulerFactory
        • Lesson 11: Advanced (Enterprise) Features
        • Lesson 12: Miscellaneous Features of Quartz
        • CronTrigger Tutorial
      • Configuration Reference
      • Migration Guide
      • API Documentation
    • Quartz 1.x
      • Tutorial
        • Lesson 1: Using Quartz
        • Lesson 2: Jobs And Triggers
        • Lesson 3: More About Jobs & JobDetails
        • Lesson 4: More About Triggers
        • Lesson 5: SimpleTrigger
        • Lesson 6: CronTrigger
        • Lesson 7: TriggerListeners and JobListeners
        • Lesson 8: SchedulerListeners
        • Lesson 9: JobStores
        • Lesson 10: Configuration, Resource Usage and SchedulerFactory
        • Lesson 11: Advanced (Enterprise) Features
        • Lesson 12: Miscellaneous Features of Quartz
      • API Documentation
  • License

Every Quartz.NET release that changed the database schema, in order, with the migration to run and what happens if you skip it.

Quartz.NET never migrates your schema. A 4.x store asked to provision its own creates a missing schema, but cannot move an existing one forward: a guarded CREATE TABLE skips a table that exists without looking inside, so a table missing a column stays missing it. Read from your current version down to your target version and run what each section lists.

Warning

Always run migration scripts in a test environment against a copy of your production database first.

How to use this page

Scripts live in database/migrations/, one folder per version. In each folder, run the file whose suffix matches your database: _sqlServer, _postgres, _mysql_innodb, _oracle, _sqlite, _firebird. Each file runs as is, with no editing.

  • Apply every version between where you are and where you are going, in ascending order, and skip none. Some migrations are optional (you can defer them), but they are cumulative, not alternatives. Skipping 3.17 and later running 3.19 leaves you without the 3.17 column; nothing goes back and adds it.
  • Re-running is safe. Every script except the SQL Server 1.0→2.0 one checks before it acts, so re-running a migration is a no-op and a half-applied migration can be re-run. The exception is SQLite ADD COLUMN: SQLite has no conditional DDL, so those statements fail on a second run.
  • A fresh install needs none of this. The scripts in database/tables/ already create everything below.

What do I need?

Coming fromGoing toWhat to run
1.xany 2.x/3.x2.0, 2.2, 2.6, then 3.x as below
2.0 / 2.13.x2.2, 2.6, 3.0, then the optional 3.x ones
2.2–2.53.x2.6, 3.0, then the optional 3.x ones
2.63.x3.0, then the optional 3.x ones
3.0–3.16latest 3.x3.17, 3.18, 3.19, 3.20 — all optional
any 3.x4.0 / 4.14.0 — mandatory, and it folds in everything from 3.17 onward
any 3.x4.24.0, then 4.2 — both mandatory
any 3.x4.3+4.0, 4.2, then 4.3 — all mandatory
4.0 / 4.14.24.2 — mandatory
4.0 / 4.14.3+4.2, then 4.3 — both mandatory
4.24.3+4.3 — mandatory; then 4.6 for 4.6+
4.34.4 / 4.5Nothing mandatory. 4.4 only with UseExecutionHistory()
4.3 / 4.4 / 4.54.6+4.6: the version, interrupt and misfire threshold columns mandatory, the history indexes optional; 4.4 only with UseExecutionHistory(); then 4.7 for 4.7+
4.64.7+4.7: the fixed-delay column, mandatory

Upgrading to 4.x is mandatory

This is the one migration you cannot defer.

  • 3.x probes for MISFIRE_ORIG_FIRE_TIME, EXECUTION_GROUP, PREFERRED_NODE and PREFERRED_NODE_AUTO when the scheduler starts. If a column is missing, it logs a warning and turns the feature off. That is why those migrations are optional on 3.x.
  • 4.x removed those probes and assumes all four columns exist. It also needs two columns 3.x never had, RETRY_POLICY and RETRY_ATTEMPT on QRTZ_TRIGGERS, and a table 3.x never had, QRTZ_PAUSED_JOB_GRPS.

So even a 3.x database with every optional migration applied does not work with 4.x until 4.0 has been applied.

At startup, 4.x checks that every table it needs is queryable, and runs one SELECT <column> … WHERE 1 = 0 per column this migration adds to a table 3.x already had. A database missing the table or any of those columns is refused, and the message names the column and the script. The check cannot see a column's type or width, so run the whole script.


Version 2.0

Required when upgrading from 1.x. Nothing later applies until this has run.

The 1.x → 2.x schema overhaul:

  • the listener tables are dropped;

  • varchar(1) flag columns become real bit columns;

  • IS_STATEFUL is replaced by IS_NONCONCURRENT and IS_UPDATE_DATA;

  • SCHED_NAME is added to every table, with the primary keys and indexes rebuilt around it.

  • Script: migrations/2.0/schema_10_to_20_upgrade_sqlServer.sql

  • SQL Server only. It is a sample, and needs adapting for other databases.

  • Not idempotent. It drops and recreates objects unconditionally, so it fails on a partially-migrated database. Run it once, on a restorable copy.

Warning

The script defaults SCHED_NAME to TestScheduler. If you have existing data, change it to match your quartz.scheduler.instanceName.

Version 2.2

Required when upgrading from 2.0 or 2.1.

Adds SCHED_TIME to QRTZ_FIRED_TRIGGERS, so recovery jobs can see both the scheduled and the actual fire time (#113).

  • Scripts: migrations/2.2/ — all databases

The column is NOT NULL with no default, so the ALTER fails on a table that holds rows. QRTZ_FIRED_TRIGGERS only holds in-flight entries, so stop the scheduler and clear it first:

DELETE FROM QRTZ_FIRED_TRIGGERS;

Version 2.6

Required when upgrading from 2.5 or earlier.

Adds TIME_ZONE_ID to QRTZ_SIMPROP_TRIGGERS and QRTZ_CRON_TRIGGERS, so a trigger's time zone survives a restart (#136).

  • Scripts: migrations/2.6/ — all databases

Tips

Older copies of this migration only altered QRTZ_SIMPROP_TRIGGERS. QRTZ_CRON_TRIGGERS needs the column too (#1985). If you upgraded 2.5→2.6 some time ago, check that both tables have it.

Version 3.0

Required when upgrading a SQL Server database from 2.6.

Converts the deprecated IMAGE columns to VARBINARY(MAX) (#291): QRTZ_CALENDARS.CALENDAR, QRTZ_JOB_DETAILS.JOB_DATA, QRTZ_BLOB_TRIGGERS.BLOB_DATA and QRTZ_TRIGGERS.JOB_DATA.

  • Script: migrations/3.0/schema_26_to_30_upgrade_sqlServer.sql
  • SQL Server only; no other dialect used IMAGE.

Version 3.17

Optional on 3.x. Required on 4.x.

Adds MISFIRE_ORIG_FIRE_TIME to QRTZ_TRIGGERS (#2899). It stores the original scheduled fire time before misfire handling overwrites it.

  • Scripts: migrations/3.17/ — all databases

If you skip it: AdoJobStore keeps working, but for misfired triggers under the "fire now" misfire policies ScheduledFireTimeUtc equals FireTimeUtc, instead of the time the trigger was supposed to fire. RAMJobStore is unaffected.

-- SQL Server
ALTER TABLE QRTZ_TRIGGERS ADD MISFIRE_ORIG_FIRE_TIME bigint NULL;

Version 3.18

Optional on 3.x. Required on 4.x.

Adds EXECUTION_GROUP to QRTZ_TRIGGERS and QRTZ_FIRED_TRIGGERS (#3004). It carries the execution group tag that per-node thread limits are enforced against. Both tables need it.

  • Scripts: migrations/3.18/ — all databases

If you skip it: execution groups still work, but the limit is applied by in-memory filtering after acquisition rather than in the acquire query, so a node acquires triggers it then has to put back.

-- SQL Server
ALTER TABLE QRTZ_TRIGGERS ADD EXECUTION_GROUP nvarchar(200) NULL;
ALTER TABLE QRTZ_FIRED_TRIGGERS ADD EXECUTION_GROUP nvarchar(200) NULL;

Version 3.19

Optional on 3.x. Required on 4.x.

Adds PREFERRED_NODE and PREFERRED_NODE_AUTO to QRTZ_TRIGGERS (#3013, #3144), which back node affinity.

  • Scripts: migrations/3.19/ — all databases

Add both columns together. Quartz enables node affinity only when both are present.

If you skip it: node affinity is unavailable. The scheduler logs a warning at startup and otherwise behaves exactly as in 3.18.

-- SQL Server
ALTER TABLE QRTZ_TRIGGERS ADD PREFERRED_NODE nvarchar(200) NULL;
ALTER TABLE QRTZ_TRIGGERS ADD PREFERRED_NODE_AUTO bit NOT NULL DEFAULT 0;

Version 3.20

Optional, performance only.

Realigns the index set with the statements AdoJobStore issues (#3203).

  • Scripts: migrations/3.20/ — all databases

  • Every Quartz statement filters SCHED_NAME first, but several shipped indexes did not lead with it and could not serve a single-scheduler lookup. PostgreSQL was worst: 9 of 11 indexes were affected, and IDX_QRTZ_T_NFT_ST had its columns in the wrong order.

  • Indexes that are a leftmost prefix of a wider one, or that no statement can drive a scan from, are dropped.

  • SQLite previously had no secondary indexes, so every acquire poll was a full table scan; this adds them.

If you skip it: everything works, with more index maintenance on writes and worse plans on reads. A database created from the current tables/ script already matches.

Tips

On a busy PostgreSQL database use CREATE INDEX CONCURRENTLY / DROP INDEX CONCURRENTLY. Neither can run inside a transaction block, so run those statements one at a time.

Version 4.0

Mandatory. See above.

  • Scripts: migrations/4.0/ — all databases

There are two files, run at different moments:

FileStatusWhen to run it
schema_30_to_40_upgrade_<db>.sqlMandatoryNow. Everything in it is safe while 3.x nodes are still up.
schema_30_to_40_indexes_<db>.sqlOptional, performance onlyOnce the last 3.x node has shut down, or straight afterwards on an offline upgrade.

The upgrade file

It applies everything from 3.17, 3.18 and 3.19, plus the retry columns and the QRTZ_PAUSED_JOB_GRPS table. Run it whether or not you applied the optional migrations; every statement is guarded, so it is safe on a partially-migrated database.

#ChangeStatus
1MISFIRE_ORIG_FIRE_TIME columnrequired
2EXECUTION_GROUP columnsrequired
3PREFERRED_NODE / PREFERRED_NODE_AUTO columnsrequired
4RETRY_POLICY / RETRY_ATTEMPT columnsrequired
5QRTZ_PAUSED_JOB_GRPS tablerequired

Sections 1–3 fold in their 3.x counterparts. Sections 4 and 5 are new in 4.x, and are why this migration is required even for a database fully migrated on 3.x.

  • RETRY_POLICY and RETRY_ATTEMPT on QRTZ_TRIGGERS back a trigger's retry policy (#3520): the policy in its stored string form, and how many retries of the executing occurrence have been made. Both are nullable with no default, so existing rows read as "no retry policy" and nothing needs backfilling.
  • QRTZ_PAUSED_JOB_GRPS holds one row per paused job group, mirroring QRTZ_PAUSED_TRIGGER_GRPS (#3336). 3.x pauses a job group without recording it, so IsJobGroupPaused answered false for every group and the pause was lost on restart. 4.x records the group names, so JobGroup.Paused is accurate and QueryJobGroups(new JobGroupQuery { Paused = true }) lists them. A group with no jobs can be paused.

From an earlier 4.0 preview: run both files again. Every statement is guarded, so the second run only applies what the preview lacked: the retry columns, this table on a preview old enough to predate it, and the index set as a pre-release moved it. On SQLite the upgrade file's sections 1–4 are not guarded (it has no conditional ADD COLUMN), so check PRAGMA table_info(QRTZ_TRIGGERS) and run only the sections whose columns are missing. The index file is guarded on SQLite too.

The index file

It supersedes 3.20 and lands the 4.x index shape. It is separate because it cannot run while 3.x nodes are up: it drops IDX_QRTZ_T_NFT_ST_MISFIRE, which 3.x drives its misfire sweep from and 4.x does not read (#3656).

  • Run it top to bottom: the drops assume the creates above them succeeded. Re-run it as often as you like; every statement is guarded.
  • A 4.x node starts fine against a database with the upgrade file but not this one; it scans where it would otherwise seek.

What it changes:

  • Adds IDX_QRTZ_J_G_N and IDX_QRTZ_T_G_N. The 4.x listing queries page with ORDER BY JOB_GROUP, JOB_NAME and ORDER BY TRIGGER_GROUP, TRIGGER_NAME, and the primary keys are name-before-group. Without these indexes each page is a scan plus a sort.
  • Reshapes IDX_QRTZ_T_NFT_ST, the acquisition index, from (SCHED_NAME, TRIGGER_STATE, NEXT_FIRE_TIME) to (SCHED_NAME, TRIGGER_STATE, NEXT_FIRE_TIME ASC, PRIORITY DESC, MISFIRE_INSTR) (#3510).
    • Acquisition orders by NEXT_FIRE_TIME ASC, PRIORITY DESC; an index with matching directions lets the engine take the first entry instead of reading and sorting every candidate. On SQL Server, a round trip against 5,000 due triggers goes from 21.6 ms and 20,395 logical reads to 0.6 ms and 8.
    • MISFIRE_INSTR lets a backlog of misfired triggers below the acquisition window be skipped inside the index rather than one table lookup at a time.
    • The name is unchanged but the columns are not, so the script drops the index before recreating it; a guarded CREATE INDEX would find the name taken and keep the old shape.
    • Firebird keeps the three-column index. Its indexes take one direction for the whole index, so it cannot express this one.
  • Drops IDX_QRTZ_T_NFT_ST_MISFIRE, which SQL Server, MySQL, Oracle and Firebird created over (SCHED_NAME, MISFIRE_INSTR, NEXT_FIRE_TIME, TRIGGER_STATE) and PostgreSQL and SQLite never did (#3656).
    • It leads with MISFIRE_INSTR, which both misfire statements compare with <> -1, and no B-tree can seek past that. Those statements filter SCHED_NAME and TRIGGER_STATE by equality and NEXT_FIRE_TIME by range: the reshaped acquisition index's leading columns.
    • Measured plan by plan on all four engines that had it, no optimizer picks it for either statement (#3608, #3656). MySQL only appeared to, because MySQLDelegate named it in a FORCE INDEX hint; the hint now names the acquisition index.
    • The drop comes after the reshape in the same file on purpose, so a schema that never took the reshape takes it first and is never left with neither index.

Node affinity needs no data migration: 3.x and 4.x store pins identically.

SQLite trigger names

database/tables/tables_sqlite.sql now names its four referential-integrity triggers QRTZ_DELETE_SIMPLE_TRIGGER, QRTZ_DELETE_SIMPROP_TRIGGER, QRTZ_DELETE_CRON_TRIGGER and QRTZ_DELETE_BLOB_TRIGGER. They were DELETE_SIMPLE_TRIGGER and so on, with no table prefix, so two Quartz schemas could not share one SQLite database whatever their table prefixes: the second CREATE TRIGGER collided with the first.

No action is required on an existing database. Nothing in Quartz names these triggers; SQLite runs them itself when a row leaves QRTZ_TRIGGERS, so an older schema keeps working under the old names. Rename them only to put a second Quartz schema in the same file:

DROP TRIGGER DELETE_SIMPLE_TRIGGER;
DROP TRIGGER DELETE_SIMPROP_TRIGGER;
DROP TRIGGER DELETE_CRON_TRIGGER;
DROP TRIGGER DELETE_BLOB_TRIGGER;

then re-create them from the current tables_sqlite.sql, replacing QRTZ_ with your table prefix. There is no migration script for this; it changes nothing Quartz stores or reads.

Tips

The 4.0 scripts live on main only. The 3.x branch links to them rather than carrying a copy, because this upgrade is decided by 4.x's schema and a copy would go stale. The links above are the right ones.


Version 4.2

Two scripts; only the first is mandatory.

  • migrations/4.2/add_continuations_<db>.sql — mandatory for 4.2 and later, all databases
  • migrations/4.2/add_execution_history_<db>.sql — optional, needed only by a store configured with UseExecutionHistory()

The continuation columns

Mandatory for 4.2 and later. The first schema change since 4.0.

Three nullable columns on QRTZ_TRIGGERS carry a conditional continuation: a trigger that waits, in the store, for another trigger's firing to end (#3805).

ColumnWhat it holds
CONTINUES_TRIGGER_NAMEThe name of the trigger whose firing this one waits for
CONTINUES_TRIGGER_GROUPThat trigger's group
CONTINUATION_CONDITIONThe outcomes that release the wait, as flags (below)

CONTINUATION_CONDITION flags: 1 on success, 2 on failure, 4 on cancellation, 8 on veto, 15 however it ends. A row that names a parent but has no condition reads as 15.

  • The waiting trigger is in a new TRIGGER_STATE, AWAITING, and is never acquired while there.
  • The parent's completion resolves it inside the parent's own lock and transaction. An outcome the condition names releases the trigger into the ordinary schedule, with its next fire time the later of "now" and its own START_TIME. Any other outcome deletes it.
  • The node that ran the parent resolves it, so a continuation survives the failure of the node that scheduled it.
  • All three columns are nullable with no default, so existing rows are valid at once and nothing needs backfilling.
  • No index is added. The lookup is SCHED_NAME = ? AND TRIGGER_STATE = 'AWAITING' AND CONTINUES_TRIGGER_NAME = ? AND CONTINUES_TRIGGER_GROUP = ?; its two leading equality columns are what IDX_QRTZ_T_NFT_ST already leads with, and AWAITING is a small partition even in a schedule that uses continuations heavily. Measure before adding one.

Rolling 4.1 → 4.2

Run the migration while 4.1 nodes are still up. A 4.1 node's trigger INSERT names its own columns, its acquisition and misfire sweeps select WAITING, and its cluster recovery touches ACQUIRED and BLOCKED, so nothing it does on its own picks up an AWAITING row. But it reads an unknown state string as waiting:

  • it reports an AWAITING row as Normal;
  • its single-trigger PauseTrigger writes PAUSED over one, which a resume then releases without its parent;
  • its reschedule rewrites one as an ordinary trigger.

A 4.1 node cannot resolve a continuation at all: a parent completing there leaves the triggers waiting on that firing where they are. Since 4.3, a node's misfire pass settles a continuation whose parent trigger no longer exists — a spent one-off that completed on a 4.1 node — as a deleted parent is settled: OnAnyOutcome is released, any other condition is parked in ERROR. A repeating parent's continuations still wait for its next completion on a 4.2+ node. So while any 4.1 node runs, do not pause, resume or reschedule a continuation from it, and:

  1. Run add_continuations_<db>.sql.
  2. Roll every node to 4.2.
  3. Then start scheduling continuations.

A 4.2 node refuses to start against a database without this migration, because it names all three columns in the statement that stores every trigger. The startup check says which column and which script.

ProvisionSchema() does not help here: it creates missing tables and never adds a column to an existing table. Run the script.

The execution history tables

Optional. add_execution_history_<db>.sql creates two tables that only UsePersistentStore(store => store.UseExecutionHistory()) reads or writes (#3771):

TableOne row per
QRTZ_EXECUTION_HISTORYFinished execution
QRTZ_MISFIRE_HISTORYMissed firing
  • QRTZ_EXECUTION_HISTORY holds the node that ran it, the job and trigger, when it fired, how long it took in ticks, whether it threw, what it said, which attempt at the occurrence it was (RETRY_ATTEMPT) and whether the trigger answered it with another (RETRY_SCHEDULED).
  • QRTZ_MISFIRE_HISTORY holds the trigger, the node that noticed, when it noticed, and the firing that was missed. Nothing ran, so there is no duration or outcome; that is why it is a separate table.
  • Both are keyed by (SCHED_NAME, ENTRY_ID). The store writes ENTRY_ID exactly as it writes QRTZ_FIRED_TRIGGERS.ENTRY_ID, because no two dialects spell an identity column the same way, and nothing reads the number.
  • Neither table has a foreign key, since a history row outlives the trigger and job it names. No other statement in the schema mentions them.
  • Each has two indexes: (SCHED_NAME, <time>) for the age query and the retention sweep, and (SCHED_NAME, INSTANCE_NAME) for the dashboard's node filter.
  • A search by job or trigger name is a scan, deliberately: it lowercases the key to match the in-memory history, which no index can serve, and the feed it searches is bounded by the retention window. RETRY_ATTEMPT and RETRY_SCHEDULED are not indexed either: the "failed after retries" filter (SUCCEEDED = 0 AND RETRY_SCHEDULED = 0) runs over the same bounded feed.

When you need it:

  • A scheduler that keeps no history never probes for these tables, so a database created by 4.0 or 4.1 keeps working untouched.
  • A store configured for history refuses to start without them, naming this script.
  • A fresh install from database/tables/ creates them either way, and so does ProvisionSchema(). This is the one 4.2 script provisioning can replace, because what is missing is whole tables, not columns of an existing one.

It is safe in a mixed cluster in both directions. A 4.0 or 4.1 node cannot see these tables, and a 4.2 node that does not ask for history neither writes nor reads them. The nodes that do ask share one history, and every row carries the instance id that produced it.

The store trims both tables itself, to ExecutionHistoryOptions.Retention (24 hours by default) and MaxEntriesPerScheduler (2,000); see the persistent store's execution history.

SQL Server memory-optimized and pre-2016 schemas

Required for a schema created from tables_sqlServerMOT.sql or tables_sqlServer_Below2016.sql under 4.2.0, when the store keeps execution history. Fixed in 4.2.1 and 4.3.

  • Under 4.2.0, those two scripts created QRTZ_EXECUTION_HISTORY without RETRY_ATTEMPT and RETRY_SCHEDULED.
  • With UseExecutionHistory() on, every history write fails with Invalid column name 'RETRY_ATTEMPT'.
  • A schema from tables_sqlServer.sql, add_execution_history_sqlServer.sql or ProvisionSchema() already has both columns.

Add the two columns. Each statement is guarded, so re-running it is safe:

IF COL_LENGTH('dbo.QRTZ_EXECUTION_HISTORY', 'RETRY_ATTEMPT') IS NULL
    ALTER TABLE [dbo].[QRTZ_EXECUTION_HISTORY] ADD [RETRY_ATTEMPT] int NOT NULL DEFAULT 0;
IF COL_LENGTH('dbo.QRTZ_EXECUTION_HISTORY', 'RETRY_SCHEDULED') IS NULL
    ALTER TABLE [dbo].[QRTZ_EXECUTION_HISTORY] ADD [RETRY_SCHEDULED] bit NOT NULL DEFAULT 0;

Version 4.3

Five scripts; the first three are mandatory (#3874, #3875, #3879).

ScriptStatusAdds
migrations/4.3/add_fire_progress_<db>.sqlMandatory for 4.3 and laterPROGRESS, PROGRESS_MESSAGE on QRTZ_FIRED_TRIGGERS
migrations/4.3/add_overlap_policy_<db>.sqlMandatory for 4.3 and laterOVERLAP_POLICY on QRTZ_TRIGGERS
migrations/4.3/add_pause_reason_<db>.sqlMandatory for 4.3 and laterPAUSE_REASON, PAUSED_BY, PAUSED_AT on QRTZ_TRIGGERS, QRTZ_PAUSED_TRIGGER_GRPS and QRTZ_PAUSED_JOB_GRPS
migrations/4.3/add_execution_log_<db>.sqlOptional: only with UseExecutionHistory()EXECUTION_LOG on QRTZ_EXECUTION_HISTORY
migrations/4.3/add_misfire_reason_<db>.sqlOptional: only with UseExecutionHistory()REASON on QRTZ_MISFIRE_HISTORY

The progress columns

ColumnWhat it holds
PROGRESSThe percentage, 0 to 100, a running job last passed to ReportProgress
PROGRESS_MESSAGEThe message it passed with it, truncated to 250 characters
  • Written at most once a second per firing, and only when the value changes, by ENTRY_ID. The row is the writing node's own, so no lock is taken.
  • Oracle declares PROGRESS_MESSAGE as VARCHAR2(1000): VARCHAR2 counts bytes, and 250 characters of UTF-8 can take 1,000.
  • Firebird declares it VARCHAR(250), which is 250 bytes in a database without a character set. The store cuts the message to 250 bytes there; see text columns that count bytes.
  • Read by the fire-instance listing, which is what the dashboard's Currently Executing page shows cluster-wide.
  • No index: the write is by primary key.
  • A 4.3 node refuses to start without them; the startup check names the column and the script.

The execution log column

EXECUTION_LOG holds the log lines an execution wrote, captured by UseExecutionLogCapture(). A large object on every dialect: nvarchar(max), TEXT, LONGTEXT, CLOB, BLOB SUB_TYPE TEXT.

  • The history listing never selects it; only the read of one entry does.
  • A store configured with UseExecutionHistory() refuses to start without it. No other store probes for it.
  • Run it only on a database that has QRTZ_EXECUTION_HISTORY: it alters that table, and fails where the table is missing. A database without the table runs 4.2/add_execution_history_<db>.sql first.

The overlap policy column

OVERLAP_POLICY is the integer of the trigger's OverlapPolicy.

ValuePolicy
NULL, 0Default
1Skip
2BufferOne
3CancelPrevious
4AllowAll
19CancelPrevious, set while a firing that started under another policy may still run; the store clears the 16 once none does
  • Read and written with the rest of the trigger's row. No index.
  • A 4.3 node refuses to start without it; the startup check names the column and the script.
  • What each policy does: Overlap Policy.

The misfire reason column

REASON says why a row of QRTZ_MISFIRE_HISTORY is there.

ValueReason
NULL, 0Missed: a misfire; the trigger's misfire instruction was applied
1Overlap: OverlapPolicy.Skip dropped the firing because the previous one was still running
  • A store configured with UseExecutionHistory() refuses to start without it. No other store probes for it.
  • Run it only on a database that has QRTZ_MISFIRE_HISTORY, as for the execution log column.

The pause columns

The same three columns on QRTZ_TRIGGERS and on both paused-group tables.

ColumnWhat it holds
PAUSE_REASONWhy it was paused, cut to 250 characters
PAUSED_BYWho asked, cut to 200 characters; quartz:retries-exhausted when the scheduler paused a trigger whose retries ran out
PAUSED_ATWhen, in ticks
  • A 4.3 node writes all three for a pause that gives a reason or a requester. A pause that gives neither runs 4.2's statements and leaves them NULL.
  • A 4.3 node clears a trigger's on its own resume. A group's row is deleted on resume.
  • Read only while the trigger is PAUSED or PAUSED_BLOCKED, or while the group's row exists.
  • Oracle declares PAUSE_REASON as VARCHAR2(1000) and PAUSED_BY as VARCHAR2(800), for the reason PROGRESS_MESSAGE is wider. Firebird declares them VARCHAR(1000) and VARCHAR(800): in a database created without a default character set, its VARCHAR counts bytes too.
  • No index. A 4.3 node refuses to start without them; the startup check names the column and the script.
  • What a pause records, and how to read it: Pausing with a Reason.

Rolling 4.2 → 4.3

Run the scripts while 4.2 nodes are still up. Every column is nullable with no default, and a 4.2 node never names them: its firings read as having reported no progress, its history rows carry no log, and its misfire rows read as misfires.

Roll every node before giving a trigger an overlap policy. A 4.2 node ignores OVERLAP_POLICY and fires the trigger as Default does, overlapping.

Roll every node before relying on a pause reason. A 4.2 node's pause leaves the columns NULL, which reads as no reason. Its resume leaves a trigger's columns behind. A 4.3 node reads them only while the trigger is paused, so a later pause of it without a reason, on either version, reports the old reason.

A fresh install from database/tables/, and ProvisionSchema(), create all fourteen columns.

Version 4.4

One script, optional: only a store configured with UseExecutionHistory() needs it (#3958).

ScriptStatusAdds
migrations/4.4/add_execution_outcome_<db>.sqlOptional: only with UseExecutionHistory()RESULT, SUMMARY, METRICS, MANUAL, FIRE_INSTANCE_ID, JOB_INPUT, JOB_INPUT_TOO_LARGE on QRTZ_EXECUTION_HISTORY; IDX_QRTZ_EH_JOB_TIME; the QRTZ_JOB_STATUS table
  • A store configured with UseExecutionHistory() refuses to start without them; the startup check names the table or column and the script. No other store probes for them.
  • Run it only on a database that has QRTZ_EXECUTION_HISTORY: its first statement alters that table. A history 4.2 created needs 4.3/add_execution_log_<db>.sql and add_misfire_reason_<db>.sql first.

The outcome columns

What a run reported through context.Result, on QRTZ_EXECUTION_HISTORY.

ColumnWhat it holdsDeclared as
RESULTThe integer of the run's JobRunResultREASON on QRTZ_MISFIRE_HISTORY
SUMMARYOne line, cut to 1,000 charactersERROR_MESSAGE
METRICSThe reported values, as JSONEXECUTION_LOG
MANUALWhether TriggerJob fired itSUCCEEDED, nullable
FIRE_INSTANCE_IDThe firing's id, linking the row to its span and log scopeQRTZ_FIRED_TRIGGERS.ENTRY_ID
  • All five are nullable with no default. A row a 4.3 node wrote leaves them NULL, and its outcome is read from SUCCEEDED.
  • FIRE_INSTANCE_ID is neither unique nor indexed. A fire instance id is not durable across a restart, and the history write is never retried.

The input columns

What the run was given, on QRTZ_EXECUTION_HISTORY, written only with ExecutionHistoryOptions.RecordInput (#4012).

ColumnWhat it holdsDeclared as
JOB_INPUTThe run's input, the string stored under QRTZ_JOB_INPUTEXECUTION_LOG
JOB_INPUT_TOO_LARGEWhether the input was over MaxInputBytes, and so not keptSUCCEEDED, nullable
  • Both are nullable with no default. JOB_INPUT is NULL when nothing was recorded.
  • The listing never reads JOB_INPUT; the single-row read does.

The job index

IDX_QRTZ_EH_JOB_TIME is (SCHED_NAME, JOB_GROUP, JOB_NAME, FIRED_TIME): one job's history by time. The table is bounded by ExecutionHistoryOptions, so the index builds in moments.

The job status table

QRTZ_JOB_STATUS holds one row per job, keyed by (SCHED_NAME, JOB_GROUP, JOB_NAME). The store writes it with each history row, so it outlives the history's retention.

ColumnWhat it holds
FIRST_FIRED_TIME, LAST_FIRED_TIMEWhen the job first fired, and when its last run fired
LAST_RESULT, LAST_SUMMARYThe last run's RESULT and SUMMARY
LAST_RUN_TIMEHow long the last run took, in ticks
LAST_INSTANCE_NAME, LAST_ENTRY_IDThe node that ran it, and its history row
LAST_SUCCESS_TIME, LAST_FAILURE_TIMEWhen a run last succeeded, and last failed
LAST_FAILURE_MESSAGEWhat the last failure said
CONSECUTIVE_FAILURES, RUN_COUNT, FAILURE_COUNTCounters, NOT NULL DEFAULT 0
  • NOT NULL: the key, FIRST_FIRED_TIME, LAST_FIRED_TIME, LAST_RESULT, LAST_RUN_TIME, LAST_INSTANCE_NAME and the counters.
  • No foreign key: a 4.3 node deleting a job must not trip on a row it has never heard of.

Byte widths

SUMMARY, LAST_SUMMARY and LAST_FAILURE_MESSAGE are declared as ERROR_MESSAGE is: VARCHAR2(4000) on Oracle and VARCHAR(1000) on Firebird. See text columns that count bytes.

Rolling 4.3 → 4.4

Run the script while 4.3 nodes are still up. Every column is nullable with no default, and a 4.3 node never names them: its history rows carry no outcome. A 4.3 node never touches QRTZ_JOB_STATUS, so the rollup counts only the runs 4.4 nodes record until every node is 4.4.

A fresh install from database/tables/, and ProvisionSchema(), create all of it.

SQL Server: one foreign key fewer

ProvisionSchema() no longer creates FK_QRTZ_BLOB_TRIGGERS_QRTZ_TRIGGERS, which tables_sqlServer.sql never created (#3949). There is no script: a database that has the key keeps it, and nothing depends on it.

Version 4.6

Four scripts: three mandatory (#4067, #4071, #4070), one optional and performance only (#4072). 4.5 changed no schema.

ScriptStatusAdds
migrations/4.6/add_scheduler_version_<db>.sqlMandatory for 4.6 and laterQUARTZ_VERSION on QRTZ_SCHEDULER_STATE
migrations/4.6/add_fired_trigger_interrupt_<db>.sqlMandatory for 4.6 and laterINTERRUPT_REQUESTED_TIME on QRTZ_FIRED_TRIGGERS
migrations/4.6/add_trigger_misfire_threshold_<db>.sqlMandatory for 4.6 and laterMISFIRE_THRESHOLD on QRTZ_TRIGGERS
migrations/4.6/add_execution_history_lookup_indexes_<db>.sqlOptional: performance onlyIDX_QRTZ_EH_FIRE_INST and IDX_QRTZ_EH_TRIG_TIME on QRTZ_EXECUTION_HISTORY

The version column

QUARTZ_VERSION is the Quartz version the node runs, as major.minor.patch (4.6.0), written with LAST_CHECKIN_TIME at every check-in.

ValueMeaning
NULLA node older than 4.6, which never writes the column
4.6.0 or laterA node whose misfire and edit writes are held to the row they read
  • Nullable, no default, VARCHAR(20) on every dialect (nvarchar(20) on SQL Server, VARCHAR2(20) on Oracle). A prerelease tag is not stored.
  • A 4.6 node fires a round of triggers whose jobs allow concurrent execution without the cluster's TRIGGER_ACCESS lock, but only while every live row of this table carries 4.6 or later. See Lock-free firing rounds.
  • No index. The column is read with the row the check-in already reads.
  • A 4.6 node refuses to start without it; the startup check names the column and the script.

The interrupt column

INTERRUPT_REQUESTED_TIME is when another node asked for the firing to be interrupted, in ticks like FIRED_TIME. NULL is a firing nobody asked to interrupt.

  • Nullable, no default; BIGINT (NUMBER(19) on Oracle).
  • Written by IScheduler.Interrupt, InterruptFireInstance and a CancelPrevious fire on a clustered store, for an executing firing another node runs. Read by the node running it after each check-in, which cancels the job's token. See Interrupting a firing on another node.
  • The row is deleted when the firing completes, request and all.
  • No index. The read filters SCHED_NAME and INSTANCE_NAME, which IDX_QRTZ_FT_INST_JOB_REQ_RCVRY leads with, and one node's rows are the firings it holds.
  • A 4.6 node refuses to start without it; the startup check names the column and the script.

The misfire threshold column

MISFIRE_THRESHOLD is the trigger's own misfire threshold in milliseconds.

ValueMeaning
NULLThe store's MisfireThreshold; every row a node older than 4.6 writes
a positive numberHow late the trigger may fire before it misfires
  • Nullable, no default, a 64-bit integer (bigint, BIGINT, NUMBER(19)).
  • No index. The misfire scan and acquisition still range over IDX_QRTZ_T_NFT_ST on NEXT_FIRE_TIME, and read the column as a residual beside it.
  • A 4.5 node ignores it, so a cluster honours a trigger's threshold only once every node is 4.6. A 4.5 node that updates the row leaves the column as it was.
  • A 4.6 node refuses to start without it; the startup check names the column and the script.

Rolling 4.5 → 4.6

Run all three mandatory scripts while 4.5 nodes are still up. All three columns are nullable with no default, and a 4.5 node never names them: its state row reads NULL.

WhileA 4.6 node
Any live row is NULL, below 4.6 or unreadableTakes TRIGGER_ACCESS for every firing round, and logs event 3517 once
Every live row is 4.6 or laterFires rounds of concurrent-allowed work without the lock, and logs event 3518 once

A row past its check-in failure threshold is a node about to be recovered and does not count. Nothing needs configuring for the rollout: roll every node, and each 4.6 node switches on its next check-in after the last 4.5 row ages out. A node downgraded under the same instance id does not keep a 4.6 stamp: its first check-in recovers and deletes its own earlier row before it inserts a fresh one.

A 4.5 node never reads INTERRUPT_REQUESTED_TIME. A request for its firing is recorded and never answered, and a 4.6 node says so once per such node (event 3520). A CancelPrevious firing still waits for one, as before. Interrupting across the cluster needs every node on 4.6.

The history lookup indexes

IndexColumnsServes
IDX_QRTZ_EH_FIRE_INSTSCHED_NAME, FIRE_INSTANCE_IDExecutionHistoryQuery.FireInstanceId: one firing's outcome
IDX_QRTZ_EH_TRIG_TIMESCHED_NAME, TRIGGER_GROUP, TRIGGER_NAME, FIRED_TIMEExecutionHistoryQuery.Trigger: one trigger's history, newest first
  • No store probes for them. Without them each lookup scans the scheduler's rows: on PostgreSQL over 1,000,000 rows, 60 ms for a firing and 261 ms for a rarely firing trigger, against under 0.2 ms with them.
  • On a database without QRTZ_EXECUTION_HISTORY the script changes nothing. SQLite cannot check for the table first, so run it there only on a database that has one.
  • A history created before 4.4 needs 4.4/add_execution_outcome_<db>.sql first: FIRE_INSTANCE_ID is one of its columns.
  • Safe under a mixed cluster: an index changes plans, not results.

A fresh install from database/tables/, and ProvisionSchema(), create both columns and both indexes.

Version 4.7

One mandatory script (#4140).

ScriptStatusAdds
migrations/4.7/add_trigger_fixed_delay_<db>.sqlMandatory for 4.7 and laterFIXED_DELAY on QRTZ_TRIGGERS

The fixed-delay column

FIXED_DELAY says a simple trigger is measured from completion.

ValueMeaning
NULLA fixed rate; every row a node older than 4.7 writes
trueThe next run is the previous run's completion plus the interval
  • Nullable, no default, a boolean (bit, BOOL, BOOLEAN, VARCHAR2(1) on Oracle, SMALLINT on Firebird).
  • On QRTZ_TRIGGERS rather than QRTZ_SIMPLE_TRIGGERS: a firing round's candidate read, which reads this table alone, decides from it whether the round takes TRIGGER_ACCESS.
  • No index. Acquisition reads it beside NEXT_FIRE_TIME, and nothing searches on it.
  • A 4.7 node refuses to start without it; the startup check names the column and the script.

Rolling 4.6 → 4.7

Run the script while 4.6 nodes are still up: the column is nullable with no default, and a 4.6 node never names it. A 4.6 node runs a trigger measured from completion at a fixed rate, beside a running firing of it, and leaves the column as it was when it updates the row. Roll every node, then make a trigger measure from completion.

See also

  • database/README.md: the same table, in the repository
  • Database Schema: what each table holds
  • Migration Guide: the rest of the 3.x → 4.x upgrade
Help us by improving this page!
Last Updated: 11/2/26, 10:32 AM
Contributors: dependabot[bot]
Prev
Database Schema
Next
Migration Guide