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

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 toDo this
Add behaviour around an existing store — logging, metrics, tenant routing, fault injectionderive from DelegatingJobStore
Support a relational database Quartz does not ship a dialect forwrite an IDriverDelegate, not a store
Keep scheduling data somewhere that is not a relational databaseimplement 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:

  1. AcquireNextTriggers(TriggerAcquisitionRequest request, ct) — reserve triggers for this node. Never return a trigger that would fire later than request.NoLaterThan, and never return more than request.MaxCount.
  2. 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 reads results[i] against triggers[i]. Return TriggerFiredResult.NotFired for a trigger that should not fire after all and TriggerFiredResult.Failed(exception) for one that could not be processed; both are handled, a ragged list is not.
  3. 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. ReleaseAcquiredTrigger is 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 as Normal — schedulable.
  • A trigger that does not exist reads as Deleted, which resolves to TriggerState.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.
  • HasMore is exact. Read one row past Take.
  • TotalCount only when asked. Take = 0 with IncludeTotalCount = true must 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. JobDetailImpl is internal, so JobBuilder is the only supported construction path — which is what the ADO store does too.
  • Triggers can be constructed directly. Quartz.Impl.Triggers.*TriggerImpl are public, and TriggerBase is public and abstract. Note that three of the five are sealed (CalendarIntervalTriggerImpl, DailyTimeIntervalTriggerImpl, RecurrenceTriggerImpl); only SimpleTriggerImpl and CronTriggerImpl can be subclassed.

Testing one

  • Behaviour: run a real scheduler over your store with UseJobStore<MyStore>() and assert through IScheduler. That is the only way to exercise the fire cycle's ordering.
  • The contract: the query rules above — ordering, HasMore, the Take = 0 count — are all testable against the store directly, with no scheduler.
  • Fault handling: DelegatingJobStore wrapping 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
Help us by improving this page!
Last Updated: 8/23/26, 6:42 AM
Contributors: Marko Lahma
Prev
Job Template
Next
A Driver Delegate for a New Database