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
    • Configuration Reference
    • JSON Configuration
    • Cron Expression Reference
    • Multi-Tenancy
    • 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
    • Retrying Failed Jobs
    • Multiple Triggers
    • Job Template
    • Running Quartz under Aspire
    • Quartz.NET with Wolverine
    • 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
      • Hosted Services Integration
      • Microsoft DI Integration
      • Multiple Schedulers with Microsoft DI
      • Observability
      • Redis Lock Handler
      • TimeZoneConverter Integration
    • 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

A Driver Delegate for a New Database

Quartz ships driver delegates for SQL Server, PostgreSQL, MySQL, Oracle, SQLite and Firebird. A database that is not one of those — or one of those behind a provider that behaves differently — needs a delegate of its own.

Start here

Do not implement IDriverDelegate. Nothing in the product does; it is a hundred-odd members, and almost all of them are the same SQL on every database. Subclass StdAdoDelegate and override the handful that differ. The six shipped dialects override ten distinct members between them, out of roughly a hundred and ten — and Firebird and PostgreSQL need only two each.

The seam

StdAdoDelegate is public and unsealed, with a public parameterless constructor:

public sealed class MyDatabaseDelegate : StdAdoDelegate
{
    // override only what differs
}

Ten members are the dialect contract. Everything else on StdAdoDelegate is an implementation step that happens to be protected virtual so the class can be composed — treat them as private.

MemberOverride when
protected virtual string? SchemaResourceNameyou want ProvisionSchema() to be able to create your tables — see Also needed
protected virtual SqlRowLimit GetRowLimit(int count)your database can limit the rows a statement returns
protected virtual string GetSelectNextTriggerToAcquireSql(TriggerAcquisitionSqlShape shape)the acquisition statement needs something else besides — MySQL's index hint is the only shipped case
protected virtual string GetSelectMisfiredTriggersToRecoverSql(int count)the same, for the misfire scan; count == -1 means "no limit"
protected virtual string GetCountMisfiredTriggersInStateSql()the counting form needs a different shape
protected virtual string ApplyPaging(string sql, bool takeLimited)OFFSET … FETCH NEXT … is not understood
protected virtual void AddPagingParameters(DbCommand cmd, int skip, int take, bool takeLimited)your paging clause names the two parameters in a different order
public virtual void AddCommandParameter(DbCommand cmd, string paramName, object? paramValue, Enum? dataType = null, int? size = null)the provider needs types or sizes set explicitly
public virtual object GetDbBooleanValue(bool value)there is no boolean column type
public virtual bool GetBooleanFromDbValue(object columnValue)the same, reading back

Row limiting

There is no ANSI row limit, so StdAdoDelegate applies none and a dialect that can limit rows says where its clause goes. Two statements carry one — trigger acquisition and the misfire scan — and both ask the same member, so a dialect says it once:

// … LIMIT n (PostgreSQL, MySQL, SQLite) — or "ROWS" on Firebird
protected override SqlRowLimit GetRowLimit(int count)
    => SqlRowLimit.AtStatementEnd("LIMIT", count);

// SELECT TOP n …                              SqlRowLimit.InProjection("TOP", count)
// SELECT * FROM ( … ) WHERE rownum <= n       SqlRowLimit.InEnclosingSelect("rownum", count)

SqlRowLimit names the three places a limit can sit, and the statement is built with the clause already in it. Nothing is spliced into finished SQL, so a dialect no longer depends on the statement starting with a particular keyword or ending with a particular clause. count is always at least one: the -1 that means "every row" is turned into SqlRowLimit.Unlimited before the member is called, so an override never has to test for it.

TriggerAcquisitionSqlShape carries everything about an acquisition attempt that changes the text of the statement — the row limit's count, and how many job-type exclusion terms the NOT IN clause needs. It is also the key the finished statement is cached under, which is why it holds the bucketed exclusion count rather than the caller's exact one. Override GetSelectNextTriggerToAcquireSql(shape) only for something a row limit cannot express; MySQL is the one shipped dialect that does, for its FORCE INDEX hint, and it still calls base and leaves the limit alone.

Paging

The default is the ANSI form, understood by SQL Server 2012+, Oracle 12c+, PostgreSQL and Firebird 3+:

 OFFSET @pageSkip ROWS FETCH NEXT @pageTake ROWS ONLY

MySQL and SQLite have no such clause, so they override both members — and they must override AddPagingParameters too, because their clause names the parameters in the other order and providers that bind positionally take them in the order the statement mentions them.

The two parameter names are the one thing the two overrides have to agree about, so they are constants rather than literals: AdoConstants.ParameterPageSkip and AdoConstants.ParameterPageTake, spliced into the statement with an @ and bound by the bare name.

protected override string ApplyPaging(string sql, bool takeLimited)
    => takeLimited
        ? sql + " LIMIT @" + AdoConstants.ParameterPageTake + " OFFSET @" + AdoConstants.ParameterPageSkip
        : sql + " LIMIT -1 OFFSET @" + AdoConstants.ParameterPageSkip;

protected override void AddPagingParameters(DbCommand cmd, int skip, int take, bool takeLimited)
{
    if (takeLimited)
    {
        AddCommandParameter(cmd, AdoConstants.ParameterPageTake, take);
    }

    AddCommandParameter(cmd, AdoConstants.ParameterPageSkip, skip);
}

takeLimited is false when the caller asked for an unbounded page (Take = int.MaxValue), which is the case a database with no offset-only form has to spell some other way — MySQL uses a LIMIT of the largest BIGINT UNSIGNED, SQLite uses LIMIT -1.

One detail to preserve: the take the base class passes is one more than the page size. That extra row is what tells the caller whether anything follows the page, which is how PagedResult<T>.HasMore is exact without a second query.

Booleans

Oracle has no boolean column type, so its delegate maps both directions:

public override object GetDbBooleanValue(bool booleanValue) => booleanValue ? "1" : "0";

public override bool GetBooleanFromDbValue(object columnValue) => Convert.ToInt32(columnValue) == 1;

GetDbBooleanValue is what every IS_DURABLE, REQUESTS_RECOVERY and similar column is bound through, so the two must agree exactly.

Parameters

AddCommandParameter is the last resort, and SQL Server's delegate shows why it is sometimes necessary: it converts booleans to 1/0, sets size = -1 for varbinary, and pins string parameters to size = 4000 to stop the server inferring a size from the value and building a separate query plan per length.

What the delegate cannot reach

Warning

The SQL statement constants — StdAdoConstants — are internal. The exact text of a statement is not a contract; the schema it addresses is, and that lives in AdoConstants, which is public.

For a delegate in your own assembly this means two things:

  • You cannot write StdAdoConstants.SqlSelectNextTriggerToAcquire. Derive your statement from what the base returns — base.GetSelectNextTriggerToAcquireSql(shape) — and transform the string, which is exactly what MySQL's .Replace("{0}TRIGGERS t", …) does. Or write the statement whole.
  • You can name tables, columns, trigger types and state values: AdoConstants.TableTriggers, AdoConstants.ColumnTriggerName, AdoConstants.StateWaiting and the rest are public precisely so a dialect can build its own SQL against the schema.

{0} is the table-prefix placeholder, and protected string ReplaceTablePrefix(string query) substitutes it. Statements the base class returns still contain it; the caller substitutes.

Tips

"Customize one statement" is not a supported operation, and that is deliberate. The six SQL hooks above cover the statements that actually differ between databases; the other ~76 are inlined ReplaceTablePrefix(StdAdoConstants.X) call sites, and the delegate is the seam — override the method that issues the statement. Every IDriverDelegate member on StdAdoDelegate is virtual for that reason, including UpdateTriggerStateFromOtherStateWithNextFireTime, which is how the lock-free acquisition path claims a trigger. Additional GetXxxSql() hooks can be added later without breaking anyone, so if you need one, ask.

The value conversions are not all seams

GetDbBooleanValue / GetBooleanFromDbValue are virtual — Oracle has no boolean column type — but GetDbDateTimeValue, GetDateTimeFromDbValue, GetDbTimeSpanValue and GetTimeSpanFromDbValue are not, and that is deliberate. UTC ticks and whole milliseconds are part of the schema contract: the preferred-node liveness SQL does raw tick arithmetic against LAST_CHECKIN_TIME and CHECKIN_INTERVAL, so a delegate that changed how an instant is stored would silently break cluster failover for pinned triggers. A database that must store DATETIME natively implements IDriverDelegate directly and owns its SQL.

Initialization

StdAdoDelegate.Initialize(DriverDelegateContext context) is public virtual, and a dialect normally does not override it — none of the six shipped ones do. Override it only to register extra trigger persistence delegates or to capture something from the context, and call base.Initialize(context) first.

DriverDelegateContext carries everything the delegate needs to issue statements:

Member
TablePrefix, SchedulerName, InstanceIdrequired
DbProvider, TypeLoaderrequired
ObjectSerializernullable
TriggerPersistenceDelegatesthe ones registered for this scheduler
TimeProviderthe scheduler's clock
CommandTimeoutfrom AdoJobStoreOptions.CommandTimeout

It arrives after construction rather than through the constructor because InstanceId is not settled until the scheduler starts — a generated instance id does not exist when the container builds the delegate.

Two of those the base class then hands back, so a delegate writing a statement of its own does not override Initialize merely to keep a second copy: protected string SchedulerName { get; }, which nearly every statement is scoped by, and protected IDbProvider DbProvider { get; }.

Registering it

builder.Services.AddQuartz(q =>
{
    q.UsePersistentStore(s =>
    {
        s.UseDriverDelegate<MyDatabaseDelegate>();
        s.UseGenericDatabase("MyProvider", connectionString);
    });
});

Order matters

Registration is first-wins (TryAdd). UseSqlServer, UsePostgres and the rest each call UseDriverDelegate<…>() internally, so UseDriverDelegate<MyDatabaseDelegate>() must come before the database method or it is silently ignored.

The delegate is constructed with ActivatorUtilities, so constructor dependencies work — take an ILogger<MyDatabaseDelegate> or anything else in the container.

For a delegate that needs more than the container can supply — a constructor argument only the caller has, or a property set before it is handed over — UseDriverDelegate(factory) takes the delegate you built. The factory is given a provider that resolves this scheduler's own parts, which is what registering IDriverDelegate against Services would not do: a named scheduler resolves its delegate under its own key and would never see an unkeyed registration. UseSerializer, UseLockHandler, UseConnectionProvider and UseTriggerPersistenceDelegate all take a factory the same way.

The legacy quartz.jobStore.driverDelegateType key still selects a delegate by type name, and stands on its own: an application that has moved store selection into code can still name its delegate in a configuration file.

Also needed: a DbMetadata and a schema

The delegate is one of three things a new database needs:

  1. The delegate — this page.
  2. A provider registration. UseGenericDatabase(provider, connectionString, describeMetadata) takes a Func<DbMetadata> describing the ADO.NET provider: its connection, command and parameter types, the parameter prefix, and how it spells a DbType. The provider name and the delegate are independent axes — the Use… shortcut methods just set both at once.
  3. DDL. Copy the closest database/tables/tables_<dialect>.sql and adjust the column types.

Running that DDL by hand is enough for the default SchemaProvisioning.Validate. To let ProvisionSchema() create the tables instead, embed the script in your own assembly (<EmbeddedResource Include="MyDatabase.sql" />) and name it from SchemaResourceName, using the manifest resource name the compiler gives it — <RootNamespace>.<folder path>.<file name>. Statements are separated by a line reading --;; rather than by a semicolon, because a semicolon ends a statement on one dialect and a line inside a stored-procedure body on another, and {0} stands where the table prefix goes:

// The schema script this delegate creates its tables from, embedded beside it with
// <EmbeddedResource Include="MyDatabase.sql" /> and named the way the compiler names one:
// <RootNamespace>.<folder path>.<file name>. Statements are separated by a line reading "--;;".
protected override string? SchemaResourceName => "MyCompany.Quartz.MyDatabase.sql";

The name is looked for in the assembly of the delegate's own type and then in each assembly up its base chain, so a delegate that subclasses a shipped dialect to change a statement or two inherits that dialect's script without carrying a copy. Left unset, ProvisionSchema() fails with a message naming this member rather than with a SQL error.

See also

  • Job Stores — how the ADO store is put together
  • A Job Store of Your Own — the layer above this one
  • Persisting a Custom Trigger Type — the other delegate seam
Help us by improving this page!
Last Updated: 9/11/26, 7:37 PM
Contributors: Marko Lahma, Claude Fable 5.1
Prev
A Job Store of Your Own
Next
Persisting a Custom Trigger Type