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 |
| Manage transactions differently from either shipped ADO.NET store | implement IJobStore directly — see The ADO.NET store is not a base class |
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
None of the shipped stores can be derived from — RAMJobStore is sealed, the two ADO.NET stores are internal — 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 paged 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.
Cluster nodes
QueryClusterNodes(ct) lists the scheduler nodes the store knows about, as ClusterNodes. It is not paged — a cluster is a handful of nodes, not a data set — and two rules bind it:
- The current node is always in the list, first, and is the only one with
IsCurrentNode = true. It is listed whether or not the store has a record of it yet. The rest follow by instance id, ordinal. - A store that keeps no membership answers with that one node,
ClusterNodeState.Alive, withLastCheckInUtcandCheckInIntervalbothnull. That is the honest answer for a store that cannot cluster, and it means a caller never has to branch onClusteredbefore asking.
A store that does keep membership reports every node it has a record of, including ones that are dead but not yet swept, and decides State with the same predicate its own failover pass uses — write that once and call it from both, so the listing can never disagree with the recovery it predicts. Overdue is a missed check-in and nothing more; Failed is the point at which the store takes the node's work over.
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.
Narrowing what a node picks up
Some decorators want less work rather than different work: a node that takes at most five triggers at a time, or one that declines a whole class of job while a maintenance window is open. Both are decisions about the acquisition request, and TriggerAcquisitionRequest is a record — so a DelegatingJobStore rewrites it with with and hands it on:
public sealed class BudgetedJobStore(IJobStore inner, int nodeBudget) : DelegatingJobStore(inner)
{
public override ValueTask<List<IOperableTrigger>> AcquireNextTriggers(
TriggerAcquisitionRequest request,
CancellationToken cancellationToken = default)
{
return base.AcquireNextTriggers(
request with { MaxCount = Math.Min(request.MaxCount, nodeBudget) },
cancellationToken);
}
}
The MaxCount rule
An override may lower MaxCount but must never raise it above what it was given. The choice between lock-free and locked acquisition is made from the request before the store reads it, so a raised count is caught only by post-acquisition validation: the surplus is released and retried. A performance hazard rather than corruption, but a silent one.
AcquireNextTriggers is called once per acquisition attempt, so a decorator runs again for every attempt rather than once per batch. Anything time-derived is recomputed, which is what makes the maintenance-window shape below work without restarting anything.
Excluding job types from acquisition
ExcludedJobTypeNames is how a node declines whole classes of work, and every shipped store honours the request-level property: the ADO.NET store threads the names into its driver delegate's criteria so the rows never leave the database, and RAMJobStore skips the candidate. Rewriting the request is therefore not a post-filter — an excluded job type never occupies one of the MaxCount rows.
public sealed class MaintenanceWindowJobStore(IJobStore inner, IMaintenanceWindow window)
: DelegatingJobStore(inner)
{
// JobType.FullName is the spelling the store persists - "Namespace.TypeName, AssemblyName".
// Type.FullName carries no assembly name and would never match a stored row.
private static readonly string reportingJobTypeName = new JobType(typeof(ReportingJob)).FullName;
public override ValueTask<List<IOperableTrigger>> AcquireNextTriggers(
TriggerAcquisitionRequest request,
CancellationToken cancellationToken = default)
{
// Asked again on every acquisition, so a window that opens between two of them takes effect on
// the next one without restarting anything.
if (!window.IsOpen)
{
return base.AcquireNextTriggers(request, cancellationToken);
}
return base.AcquireNextTriggers(
request with { ExcludedJobTypeNames = [reportingJobTypeName] },
cancellationToken);
}
}
Two things to get right:
- Name the type the way the store persists it. That is
JobType.FullName—Namespace.TypeName, AssemblyName, the same stringTriggerAcquireResult.JobTypeNamecarries and the same one the ADO schema keeps inJOB_CLASS_NAME.Type.FullNamehas no assembly name and will never match a stored row. - Matching is exact. There is no prefix or wildcard form. The SQL comparison follows the
JOB_CLASS_NAMEcolumn's collation, so its case sensitivity is the database's, not .NET's; the in-memory store compares ordinally. Rows written by Quartz 2.x or 3.x can carry an older spelling, and the read side never rewrites a stored name, so an exclusion will not match those.
Entries must be non-blank and there may be at most 1000 of them, both checked when the request is constructed; 1000 is Oracle's ceiling on an IN list.
The ADO.NET store is not a base class
AdoJobStoreBase, LocalTransactionJobStore and ExternalTransactionJobStore are internal. They are still what quartz.jobStore.type names and still what UsePersistentStore builds, so a configuration file needs no change — but they are not something to derive from, and in code the choice between the two is a call rather than a type argument: UsePersistentStore() gives you the local-transaction store, and store.UseAmbientTransactions() inside its callback gives you the other.
Deriving from the base was never the seam it looked like. Every protected member below its two abstract ones is the connection-taking twin of the public member above it — AddJob(conn, …) beside AddJob(job, …) — because the public one takes the lock and the twin does the work. Overriding one of those changes half of an operation.
What to reach for instead depends on what the override did:
| What you were overriding for | What to do |
|---|---|
| Narrowing acquisition | DelegatingJobStore, rewriting the request — see Narrowing what a node picks up |
| Logging, metrics, tenant routing, fault injection | DelegatingJobStore — see Decorating a store |
| A relational database Quartz ships no dialect for | A Driver Delegate for a New Database |
| Classifying one more of your driver's failures as retryable | AdoJobStoreOptions.IsTransient — see What counts as transient |
| A different transaction model from either shipped store | Implement IJobStore, which is public and stays public. If the two shipped stores nearly fit, open an issue — that is a gap worth hearing about rather than working around |
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. All five are subclassable — three of them were sealed during 4.x's development and reopened for exactly this. Pair a subclassed trigger with a serializer derived from that trigger's built-in serializer, which is public and unsealed for the same reason;BuiltInTriggerSerializerDerivationTestguards both halves, in both JSON packages. See Persisting a Custom Trigger Type.
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
