Quartz.NETQuartz.NET
Home
Features
Discussions
NuGet
GitHub
Home
Features
Discussions
NuGet
GitHub
  • 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
  • Unreleased Releases

    • Quartz 4.x
      • 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 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
      • Tenancy Patterns
      • Database Schema
      • Database Schema Changes
      • Migration Guide
      • Troubleshooting
      • API Documentation
      • How To's
        • One-Off Job
        • Rescheduling Jobs
        • Multiple Triggers
        • Job Template
        • 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

          • 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
  • 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

Persisting a Custom Trigger Type

The ADO job store knows how to store the five shipped trigger families. A trigger type of your own — or one deriving from a shipped one with extra properties — needs an ITriggerPersistenceDelegate to say how its schedule is written and read.

Without one, the store falls back to serializing the whole trigger into QRTZ_BLOB_TRIGGERS. That works, and it is a blob: unqueryable, and coupled to your type's shape forever.

The easy path: SIMPROP_TRIGGERS

QRTZ_SIMPROP_TRIGGERS is a generic side table with two strings, two ints, two longs, two decimals, two booleans, a third string and a time zone id. If your schedule fits in those, derive from SimplePropertiesTriggerPersistenceDelegateBase and write four members:

public sealed class BusinessDayTriggerPersistenceDelegate : SimplePropertiesTriggerPersistenceDelegateBase
{
    public override string GetHandledTriggerTypeDiscriminator() => "BUSDAY";

    public override bool CanHandleTriggerType(IOperableTrigger trigger)
        => trigger is BusinessDayTriggerImpl impl && !impl.HasAdditionalProperties;

    protected override SimplePropertiesTriggerProperties GetTriggerProperties(IOperableTrigger trigger)
    {
        BusinessDayTriggerImpl t = (BusinessDayTriggerImpl) trigger;
        return new SimplePropertiesTriggerProperties
        {
            Int1 = t.SkipCount,
            Long1 = t.TimesTriggered,
            String1 = t.CalendarSystem,
            TimeZoneId = t.TimeZone.Id,
        };
    }

    protected override TriggerPropertyBundle GetTriggerPropertyBundle(SimplePropertiesTriggerProperties props)
    {
        BusinessDayScheduleBuilder schedule = BusinessDayScheduleBuilder.Create()
            .SkippingDays(props.Int1)
            .InCalendarSystem(props.String1!)
            .InTimeZone(TimeZones.FindById(props.TimeZoneId!));

        long timesTriggered = props.Long1;
        return new TriggerPropertyBundle(
            schedule,
            t => ((BusinessDayTriggerImpl) t).TimesTriggered = timesTriggered);
    }
}

Everything else — the four SQL statements, parameter binding, the reader — is done for you. Note what is not virtual: Initialize(TriggerPersistenceDelegateContext) is a plain public void on the base that sets three protected properties (TablePrefix, SchedulerName, DbAccessor). Read those rather than overriding it. The statements are private const for the same reason: they name every column the base class writes, so a subclass replacing one would either write the same statement again or write one the base's parameter binding does not match.

The columns

PropertyColumn
String1, String2, String3STR_PROP_1..3
Int1, Int2INT_PROP_1..2
Long1, Long2LONG_PROP_1..2
Decimal1, Decimal2DEC_PROP_1..2
Boolean1, Boolean2BOOL_PROP_1..2 (through the dialect's boolean conversion)
TimeZoneIdTIME_ZONE_ID

They are deliberately anonymous: the schema is fixed, and a family that needs a fourth string is out of luck rather than adding a column.

Tips

TIME_ZONE_ID got a column of its own in 2.6. A delegate reading a row written before that finds the id in String2 instead — CalendarIntervalTriggerPersistenceDelegate implements exactly that fallback, and is worth copying if your table has old rows.

The discriminator

GetHandledTriggerTypeDiscriminator() returns the value written into QRTZ_TRIGGERS.TRIGGER_TYPE, and read back to find the delegate again. The shipped values are SIMPLE, CRON, CAL_INT, DAILY_I, RECUR and BLOB. The column is VARCHAR(8), so keep yours short — and do not collide with those six.

TriggerPropertyBundle and applyState

A trigger is rebuilt through TriggerBuilder, which carries a schedule but not runtime counters. That is what the second constructor parameter is for:

new TriggerPropertyBundle(scheduleBuilder, t => ((MyTriggerImpl) t).TimesTriggered = timesTriggered);

Pass null — or use the one-argument constructor — when your delegate carries no state beyond the schedule; the cron delegate does exactly that. The store applies the fire state, then your applier, then the routing state, in that order.

The full path: your own table

ITriggerPersistenceDelegate directly, when the schedule does not fit the generic columns:

Member
void Initialize(TriggerPersistenceDelegateContext context)no default implementation — a delegate that does not read the context has no accessor to prepare commands with, and would fail at its first statement rather than at startup
bool CanHandleTriggerType(IOperableTrigger trigger)
string GetHandledTriggerTypeDiscriminator()
ValueTask<int> InsertExtendedTriggerProperties(conn, trigger, state, jobDetail, ct)
ValueTask<int> UpdateExtendedTriggerProperties(conn, trigger, state, jobDetail, ct)
ValueTask<int> DeleteExtendedTriggerProperties(conn, triggerKey, ct)
ValueTask<TriggerPropertyBundle> LoadExtendedTriggerProperties(conn, triggerKey, ct)
TriggerPropertyBundle ReadTriggerPropertyBundle(DbDataReader rs)

There is one default interface method: the batch LoadExtendedTriggerProperties(conn, IReadOnlyCollection<TriggerKey>, ct), which loops the single-key overload. Override it when your table can answer a whole page in one statement.

TriggerPersistenceDelegateContext carries three things: SchedulerName, TablePrefix, and DbAccessor — command preparation and parameter binding for the type table this delegate owns, which is the driver delegate itself. Bind SCHED_NAME in every statement, and substitute the table prefix.

You will also need DDL for the table, in every dialect you support, and a migration script — see database/README.md.

Registering it

builder.Services.AddQuartz(q =>
{
    q.UsePersistentStore(s =>
    {
        s.UseSqlServer(connectionString);
        s.UseTriggerPersistenceDelegate<BusinessDayTriggerPersistenceDelegate>();
    });
});

There is a factory overload too, UseTriggerPersistenceDelegate(Func<IServiceProvider, ITriggerPersistenceDelegate>), for a delegate whose constructor takes values rather than services. Either way the delegate is constructed with ActivatorUtilities, so constructor dependencies work and no parameterless constructor is required.

Registration is an enumerable — the five built-ins are always present, and yours is added to them. Registering the same type twice collapses to one.

Ordering

The five built-in delegates are consulted first, and matching is first-wins. A delegate for a type deriving from a shipped trigger will therefore never be reached unless the built-in one declines it — which is what HasAdditionalProperties is for. See below.

Initialize is called by the driver delegate, not the store, once at scheduler startup: the store hands the registered delegates to StdAdoDelegate.Initialize, which builds a TriggerPersistenceDelegateContext for each and calls it before adding it to the list.

What else a custom trigger type needs

A persistence delegate is one of four pieces:

1. An IOperableTrigger. In practice derive from TriggerBase, which is public and abstract, and implement GetScheduleBuilder().

Tips

Of the five shipped trigger implementations, only SimpleTriggerImpl and CronTriggerImpl are subclassable — CalendarIntervalTriggerImpl, DailyTimeIntervalTriggerImpl and RecurrenceTriggerImpl are sealed.

2. An IScheduleBuilder. The store rebuilds a trigger as TriggerBuilder.Create()…WithSchedule(bundle.ScheduleBuilder), so the schedule has to be reproducible from a builder.

3. HasAdditionalProperties, if you derive from a built-in trigger. TriggerBase declares public virtual bool HasAdditionalProperties => false. Override it to return true and the built-in delegate for the base type declines to handle your trigger, which is what lets yours be reached — and what makes the store fall back to a BLOB if you never write one.

4. A serializer, for the BLOB path and for job-data round-tripping:

public sealed class BusinessDayTriggerSerializer : TriggerSerializer<BusinessDayTriggerImpl>
{
    public override string TriggerTypeName => "BusinessDayTrigger";
    // CreateScheduleBuilder / SerializeFields / DeserializeFields
}
s.UseSystemTextJsonSerializer(registry =>
    registry.AddTriggerSerializer<BusinessDayTriggerImpl>(new BusinessDayTriggerSerializer()));

The built-in serializers are public and unsealed on purpose: a trigger deriving from a built-in one pairs with a serializer deriving from the built-in one, overriding SerializeFields / DeserializeFields and calling the base so the built-in fields keep their stored shape.

Warning

UseSystemTextJsonSerializer(configure) with a callback captures a per-scheduler registry that is not published to the container. Called with no callback, the serializer reads the container-wide registry instead. Pick one: registering custom serializers in the callback and then expecting the HTTP client to know about them will not work.

Tips

If you also use RAMJobStore, note that its trigger-type discriminator is a hard-coded switch that a custom type falls off the end of into the blob branch. That is harmless in memory, but it means a custom trigger behaves differently in the two stores — worth knowing when a test passes in memory and fails against a database.

See also

  • A Driver Delegate for a New Database — the other delegate seam
  • A Job Store of Your Own — when the storage model itself is different
  • JSON Serialization — the serializer registry in full
Help us by improving this page!
Last Updated: 8/23/26, 6:42 AM
Contributors: Marko Lahma
Prev
A Driver Delegate for a New Database
Next
A Lock Handler of Your Own