Quartz.NETQuartz.NET
Home
Features
Blog
Discussions
NuGet
GitHub
Home
Features
Blog
Discussions
NuGet
GitHub
  • Getting Started

    • Overview
    • 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 Execution Middleware
      • 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
    • Before You Go Live
    • Operating a Cluster
    • Log Events
    • Tenancy Patterns
    • Database Schema
    • Database Schema Changes
    • Migration Guide
    • Troubleshooting
    • API Documentation
  • How To's
    • One-Off Job
    • Rescheduling Jobs
    • Retrying Failed Jobs
    • Multiple Triggers
    • Job Template
    • Running Quartz under Aspire
    • Quartz.NET with Wolverine
    • Embedding Quartz in a Library
    • Running under an External Leader Election
    • Publishing Trimmed and Native AOT
    • Extending Quartz: what is open, what is closed, and how to ask
    • 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

      • Aspire Integration
      • 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
  • Quartz 3.x

    • 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
  • 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
Manage transactions differently from either shipped ADO.NET storeimplement 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:

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

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, with LastCheckInUtc and CheckInInterval both null. That is the honest answer for a store that cannot cluster, and it means a caller never has to branch on Clustered before 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 string TriggerAcquireResult.JobTypeName carries and the same one the ADO schema keeps in JOB_CLASS_NAME. Type.FullName has 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_NAME column'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 forWhat to do
Narrowing acquisitionDelegatingJobStore, rewriting the request — see Narrowing what a node picks up
Logging, metrics, tenant routing, fault injectionDelegatingJobStore — see Decorating a store
A relational database Quartz ships no dialect forA Driver Delegate for a New Database
Classifying one more of your driver's failures as retryableAdoJobStoreOptions.IsTransient — see What counts as transient
A different transaction model from either shipped storeImplement 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. 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. 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; BuiltInTriggerSerializerDerivationTest guards 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 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: 9/11/26, 7:37 PM
Contributors: Marko Lahma, Claude Fable 5.1
Prev
Extending Quartz: what is open, what is closed, and how to ask
Next
A Driver Delegate for a New Database