Persisting a Custom Trigger Type
The ADO job store stores the five shipped trigger families. Your own trigger type, or a shipped one with extra properties, needs an ITriggerPersistenceDelegate to write and read its schedule. Without one, the whole trigger is serialized into QRTZ_BLOB_TRIGGERS: unqueryable, and tied to your type's shape.
The easy path: SIMPROP_TRIGGERS
QRTZ_SIMPROP_TRIGGERS is a generic side table: two strings, two ints, two longs, two decimals, two booleans, a third string and a time zone id. If your schedule fits, 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);
}
}
The base supplies the four SQL statements, parameter binding and the reader.
Initialize(TriggerPersistenceDelegateContext)is a plain, non-virtualpublic voidthat sets the protectedTablePrefix,SchedulerNameandDbAccessor. Read those; do not override it.- The statements are
private const, because they name every column the base binds.
The columns
| Property | Column |
|---|---|
String1, String2, String3 | STR_PROP_1..3 |
Int1, Int2 | INT_PROP_1..2 |
Long1, Long2 | LONG_PROP_1..2 |
Decimal1, Decimal2 | DEC_PROP_1..2 |
Boolean1, Boolean2 | BOOL_PROP_1..2 (through the dialect's boolean conversion) |
TimeZoneId | TIME_ZONE_ID |
The schema is fixed; a family needing a fourth string cannot add a column.
Tips
TIME_ZONE_ID got its own column in 2.6; older rows keep the id in String2. CalendarIntervalTriggerPersistenceDelegate implements that fallback; copy it if you have old rows.
The discriminator
GetHandledTriggerTypeDiscriminator() returns the value written to QRTZ_TRIGGERS.TRIGGER_TYPE and used to find the delegate on read. The column is VARCHAR(8); keep it short and avoid SIMPLE, CRON, CAL_INT, DAILY_I, RECUR and BLOB.
TriggerPropertyBundle and applyState
A trigger is rebuilt through TriggerBuilder, which has no runtime counters. The second constructor parameter restores them:
new TriggerPropertyBundle(scheduleBuilder, t => ((MyTriggerImpl) t).TimesTriggered = timesTriggered);
With no state beyond the schedule, pass null or use the one-argument constructor (as the cron delegate does). The store applies the fire state, then your applier, then the routing state.
The full path: your own table
Implement ITriggerPersistenceDelegate when the generic columns do not fit:
void Initialize(TriggerPersistenceDelegateContext context)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)
Then:
Initializehas no default implementation, so a delegate cannot skip the context and fail at its first statement instead of at startup.- The one default interface method, the batch
LoadExtendedTriggerProperties(conn, IReadOnlyCollection<TriggerKey>, ct), loops the single-key overload. Override it if your table can return a page in one statement. TriggerPersistenceDelegateContextcarriesSchedulerName,TablePrefixandDbAccessor(the driver delegate itself: command preparation and parameter binding). BindSCHED_NAMEin every statement and substitute the table prefix.- Ship DDL for 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>();
});
});
UseTriggerPersistenceDelegate(Func<IServiceProvider, ITriggerPersistenceDelegate>)is for a delegate whose constructor takes values rather than services.- Delegates are built with
ActivatorUtilities: constructor dependencies work, and no parameterless constructor is needed. - Registration is enumerable: yours is added to the five built-ins. Registering a type twice collapses to one.
- The driver delegate calls
Initialize, once at startup: the store passes the registered delegates toStdAdoDelegate.Initialize, which builds aTriggerPersistenceDelegateContextfor each and calls it before adding the delegate to its list.
Ordering
The five built-in delegates are consulted first, and the first match wins. A delegate for a type deriving from a shipped trigger is reached only if the built-in one declines it, which is what HasAdditionalProperties is for.
What else a custom trigger type needs
- An
IOperableTrigger: derive from the public abstractTriggerBaseand implementGetScheduleBuilder(). - An
IScheduleBuilder: the store rebuilds a trigger asTriggerBuilder.Create()…WithSchedule(bundle.ScheduleBuilder). HasAdditionalProperties, when deriving from a built-in trigger.TriggerBasedeclarespublic virtual bool HasAdditionalProperties => false. Returntrueso the built-in delegate declines your trigger; with no delegate of your own, the store falls back to a BLOB.- A serializer, for the BLOB path and job-data round-tripping.
Tips
All five shipped implementations are subclassable: SimpleTriggerImpl, CronTriggerImpl, CalendarIntervalTriggerImpl, DailyTimeIntervalTriggerImpl and RecurrenceTriggerImpl. Deriving from one is the shortest route to one of those with something added; pair it with a serializer derived from that trigger's built-in serializer.
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: derive, override
SerializeFields/DeserializeFields, and call the base so the built-in fields keep their stored shape. Quartz.Serialization.Newtonsoftuses the same names (NewtonsoftJsonSerializerRegistry.AddTriggerSerializer<T>(…)throughUseNewtonsoftJsonSerializer(…)), so a custom trigger ports by changing the registration.- Job data differs: declare your own value type inside a
JobDataMapwithAddJobDataValueType<T>()for Newtonsoft andAddTypeInfoResolver(…)for System.Text.Json. Both refuse an undeclared type at write time, naming the method.
Warning
UseSystemTextJsonSerializer(configure) with a callback uses a per-scheduler registry not published to the container; with no callback it reads the container-wide registry. Serializers registered in the callback are unknown to the HTTP client.
Tips
RAMJobStore's trigger-type discriminator is a hard-coded switch; custom types fall to its blob branch. That is harmless in memory, but means a test can pass in memory and fail 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
