A Job Store of Your Own
IJobStore is where scheduling data lives. Quartz ships two implementations — in memory, and ADO.NET over a relational database — and the interface is public so a third can keep it somewhere else: a document database, a key-value store, a service.
Before writing one, be clear about which of three jobs you are doing, because they have different answers.
| You want to | Do this |
|---|---|
| Add behaviour around an existing store — logging, metrics, tenant routing, fault injection | derive from DelegatingJobStore |
| Support a relational database Quartz does not ship a dialect for | write an IDriverDelegate, not a store |
| Keep scheduling data somewhere that is not a relational database | implement IJobStore directly |
Decorating a store
DelegatingJobStore forwards every operation to another store, and every member is virtual:
public sealed class MetricsJobStore(IJobStore inner, IMeterFactory meters) : DelegatingJobStore(inner)
{
private readonly Histogram<double> acquireDuration = meters
.Create("App.Quartz")
.CreateHistogram<double>("app.quartz.acquire.duration", "s");
public override async ValueTask<List<IOperableTrigger>> AcquireNextTriggers(
TriggerAcquisitionRequest request,
CancellationToken cancellationToken = default)
{
long start = Stopwatch.GetTimestamp();
try
{
return await base.AcquireNextTriggers(request, cancellationToken);
}
finally
{
acquireDuration.Record(Stopwatch.GetElapsedTime(start).TotalSeconds);
}
}
}
q.UseJobStore(sp => new MetricsJobStore(
ActivatorUtilities.CreateInstance<RAMJobStore>(sp),
sp.GetRequiredService<IMeterFactory>()));
protected IJobStore InnerJobStore reaches the real store through however many layers are in the way.
Tips
The shipped stores are sealed, and decoration is why. RAMJobStore holds a lock while it mutates several indexes in a fixed order and raises notifications after releasing it — none of which an override can be asked to preserve. Wrap it, and change what you meant to change.
A store that keeps scheduling data somewhere new should implement IJobStore directly rather than derive from this.
Registering a store
Four overloads, all singleton and all keyed by scheduler name for a named scheduler:
q.UseJobStore<MyStore>(); // container-constructed
q.UseJobStore<MyStore, MyStoreOptions>(o => …); // plus its own options type
q.UseJobStore(existingInstance); // one you built
q.UseJobStore(sp => new MyStore(…)); // a factory, e.g. for a decorator
The generic forms construct the store with ActivatorUtilities through a scheduler-scoped view of the container, so a store written against the scheduler's own collaborators behaves the same under a named scheduler as under the default one. Take what you need:
public sealed class DocumentJobStore(
ISchedulerSignaler signaler,
ITypeLoader typeLoader,
TimeProvider timeProvider,
IObjectSerializer serializer,
IOptions<MyStoreOptions> options,
ILogger<DocumentJobStore> logger) : IJobStore
{
// ...
}
Warning
Registration is TryAdd, so first wins. UseInMemoryStore() and UsePersistentStore(…) register a store too — call UseJobStore<MyStore>() instead of them, not after them.
A TOptions resolved through IOptions<TOptions> must keep its public parameterless constructor when the application is trimmed.
Initialize and identity
ValueTask Initialize(SchedulerIdentity identity, CancellationToken cancellationToken = default);
Nearly everything a store needs — the type loader, the signaler, the time provider — is supplied through its constructor. What remains here is the scheduler's identity, which is not settled until the container has built the graph, plus work that has to happen before the scheduler runs and cannot be done during construction: verifying a schema, opening a connection, starting a background scan.
SchedulerIdentity carries SchedulerName and InstanceId, both required. Record the instance id against the firings this node owns, so QueryFireInstances can say which node is running what.
It is called once, after the scheduler is built and before plugins initialize.
The contract that is easy to get wrong
The fire cycle
Three members run in a fixed sequence, once per acquisition batch:
AcquireNextTriggers(TriggerAcquisitionRequest request, ct)— reserve triggers for this node. Never return a trigger that would fire later thanrequest.NoLaterThan, and never return more thanrequest.MaxCount.TriggersFired(triggers, ct)— the scheduler is about to run them. The returned list must be the same length as the input and index-aligned with it. The caller readsresults[i]againsttriggers[i]. ReturnTriggerFiredResult.NotFiredfor a trigger that should not fire after all andTriggerFiredResult.Failed(exception)for one that could not be processed; both are handled, a ragged list is not.TriggeredJobComplete(trigger, jobDetail, instruction, ct)— the firing is over. This is what releases a[DisallowConcurrentExecution]job's siblings, and the scheduler calls it even on paths where the job never ran.ReleaseAcquiredTriggeris only for a trigger that was acquired and never fired.
Also implement TimeSpan GetAcquireRetryDelay(int failureCount), called when AcquireNextTriggers fails more than once in succession. Return something between 20 milliseconds and 10 minutes.
Trigger state
Every store keeps its triggers in one vocabulary — StoredTriggerState, nine members — and resolves to the TriggerState callers see through one function, so two stores cannot report different states for the same situation:
TriggerState reported = TriggerStateResolver.Resolve(stored, isExecuting);
The precedence is None > Error > Paused > Executing > Blocked > Complete > Normal. Paused and error outrank executing because they are the facts an operator has to act on, and both remain true while a previously started execution finishes. Executing outranks blocked so that the trigger which actually started the running job stays distinguishable from the siblings gated behind it.
Two more rules to inherit rather than reinvent:
- A stored value this version does not recognise reads as
Waiting, and is reported asNormal— schedulable. - A trigger that does not exist reads as
Deleted, which resolves toTriggerState.None.
StoredTriggerStates.ToStoredValue() / FromStoredValue() map to and from the persisted strings, and are public for exactly this.
Queries
The six Query… members are abstract, and three rules keep them consistent with the shipped stores:
- Order by group, then name, ordinal. Fire instances add fire instance id as a third key, because one trigger can have several firings in flight and group plus name would not order them.
HasMoreis exact. Read one row pastTake.TotalCountonly when asked.Take = 0withIncludeTotalCount = truemust skip the row query entirely — that is the counting idiom.
QueryFireInstances answers for the whole cluster if the store keeps firings durably, and for its own process otherwise, which is the whole of an in-memory store's world. FireInstance.JobKey is null while a firing is only Acquired — the job is not loaded until it starts.
Bulk members
Many key-set members — PauseJobs(keys), ResumeTriggers(keys), DeleteJobs(keys) and so on — have default interface implementations that loop the single-key member. Correct for any store, and one lock or round trip per key. Override the ones your store can do in one pass, and keep the default for the rest.
Two properties that are answers, not settings
bool Clustered and bool SupportsPersistence are read-only because they describe what the store is. A store that cannot cluster answers false and means it.
Deriving from AdoJobStoreBase
If your storage is relational but the transaction model differs — you manage transactions elsewhere, or lock differently — derive from AdoJobStoreBase rather than writing a store. It has exactly two abstract members:
protected abstract ValueTask<ConnectionAndTransactionHolder> GetLocalTransactionConnection(CancellationToken ct = default);
protected abstract ValueTask<T> ExecuteInLock<T>(
SchedulerLock? lockKind,
Func<ConnectionAndTransactionHolder, ValueTask<T>> txCallback,
CancellationToken ct = default);
LocalTransactionJobStore and ExternalTransactionJobStore are the two shipped answers. An override of GetLocalTransactionConnection has to start with GetEnlistedConnection.
Four members are protected virtual, and one of them is a real extension point:
protected virtual TriggerAcquisitionCriteria CreateAcquisitionCriteria(TriggerAcquisitionRequest request);
It maps the store-level request onto the criteria the driver delegate reads. Start from the base and return a with copy — the criteria are a record, so with leaves everything the base decided in place:
protected override TriggerAcquisitionCriteria CreateAcquisitionCriteria(TriggerAcquisitionRequest request)
{
TriggerAcquisitionCriteria criteria = base.CreateAcquisitionCriteria(request);
return criteria with { MaxCount = Math.Min(criteria.MaxCount, this.nodeBudget) };
}
The MaxCount rule
An override may lower MaxCount but must never raise it above the request's. The choice between lock-free and locked acquisition was already made from the request before this factory runs, so a raised count is only caught by post-acquisition validation, and the surplus is released and retried — a performance hazard rather than corruption, but a silent one.
It is called once per acquisition attempt, inside the store's internal retry loop, so an override runs again for every retry rather than once per AcquireNextTriggers call. Anything time-derived is recomputed, which is deliberate.
TriggerAcquisitionCriteria is the designated place for future acquisition filtering, so a property added later will default to "no additional filtering" — an override that starts from base and adjusts one field keeps working.
Rebuilding jobs and triggers
A store that reads its data back has to reconstruct IJobDetail and IOperableTrigger. The two are not symmetric:
- Jobs go through
JobBuilder.JobDetailImplis internal, soJobBuilderis the only supported construction path — which is what the ADO store does too. - Triggers can be constructed directly.
Quartz.Impl.Triggers.*TriggerImplare public, andTriggerBaseis public and abstract. Note that three of the five aresealed(CalendarIntervalTriggerImpl,DailyTimeIntervalTriggerImpl,RecurrenceTriggerImpl); onlySimpleTriggerImplandCronTriggerImplcan be subclassed.
Testing one
- Behaviour: run a real scheduler over your store with
UseJobStore<MyStore>()and assert throughIScheduler. That is the only way to exercise the fire cycle's ordering. - The contract: the query rules above — ordering,
HasMore, theTake = 0count — are all testable against the store directly, with no scheduler. - Fault handling:
DelegatingJobStorewrapping your store lets a test make one member fail.
See Testing.
See also
- Job Stores — the shipped stores and what they guarantee
- A Driver Delegate for a New Database — the right seam for a relational database
- Querying Jobs and Triggers — the query contract, from the caller's side
