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
      • Compile-Time Checks
      • Declaring Jobs with Attributes
      • Delegate Jobs
    • Configuration Reference
    • JSON Configuration
    • Cron Expression Reference
    • Multi-Tenancy
    • Comparison
    • 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
    • Backfill
    • Retrying Failed Jobs
    • Pausing with a Reason
    • Job Continuations
    • Overlap Policy
    • Progress and Execution Logs
    • Job Outcomes
    • Multiple Triggers
    • Job Template
    • Running Quartz under Aspire
    • Quartz.NET with Wolverine
    • Coming from Hangfire
    • Coming from TickerQ
    • 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
      • Weasel Schema Management
    • 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 holds scheduling data. Quartz ships an in-memory store and an ADO.NET store; implement the public interface to keep the data elsewhere (a document database, a key-value store, a service).

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; 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 any number of layers. A store that keeps data somewhere new implements IJobStore instead.

Tips

The shipped stores cannot be derived from: RAMJobStore is sealed and the ADO.NET stores are internal. RAMJobStore mutates several indexes under one lock in a fixed order and notifies after releasing it, which no override could preserve. Wrap it instead.

Forward AcquireNextTriggersAndFireDue to keep firing on acquisition

The scheduler calls AcquireNextTriggersAndFireDue. The shipped stores fire the triggers already due in the operation that acquires them; on a persistent store that saves a transaction a round. DelegatingJobStore does not forward it: it answers through its own AcquireNextTriggers and fires nothing, so an override of AcquireNextTriggers or TriggersFired still sees every trigger. The decorator keeps working, at two transactions a round. Forward the call to keep the saving:

public sealed class RoundCountingJobStore(IJobStore inner) : DelegatingJobStore(inner)
{
    private long rounds;

    public long Rounds => Interlocked.Read(ref rounds);

    // The member the scheduler calls. Forwarded, the inner store fires what is already due in the
    // transaction that acquires it. Left to DelegatingJobStore, it fires nothing, and the scheduler
    // fires every trigger in a second transaction.
    public override ValueTask<TriggerAcquisitionResult> AcquireNextTriggersAndFireDue(
        TriggerAcquisitionRequest request,
        CancellationToken cancellationToken = default)
    {
        Interlocked.Increment(ref rounds);
        return InnerJobStore.AcquireNextTriggersAndFireDue(request, cancellationToken);
    }
}

A decorator that rewrites the request forwards it rewritten, as BudgetedJobStore in Narrowing what a node picks up does.

Registering a store

All four overloads register a singleton, 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 from a scheduler-scoped view of the container, so it behaves the same under a named scheduler. 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(…) also register a store: call UseJobStore<MyStore>() instead of them, not after.

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);

Called once, after the scheduler is built and before plugins initialize. The constructor supplies the type loader, signaler and time provider; Initialize supplies the identity, which is settled only after the graph is built, and is where to verify a schema, open a connection or start 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 runs what.

The contract that is easy to get wrong

The fire cycle

Once per acquisition batch, in order:

  1. AcquireNextTriggersAndFireDue(TriggerAcquisitionRequest request, ct) — the scheduler's call. The default interface member calls AcquireNextTriggers and returns everything in Pending, so a store need not implement it. One that does fires the triggers already due as TriggersFired would, in TriggerAcquisitionResult.Due and Fired, index-aligned, and returns the rest in Pending.
  2. AcquireNextTriggers(TriggerAcquisitionRequest request, ct) — reserve triggers. Never return one firing later than request.NoLaterThan, or more than request.MaxCount.
  3. TriggersFired(triggers, ct) — called for the Pending triggers once they are due. Return a list the same length as the input, index-aligned; the caller reads results[i] against triggers[i]. Use TriggerFiredResult.NotFired for a trigger that should not fire after all and TriggerFiredResult.Failed(exception) for one that could not be processed.
  4. TriggeredJobComplete(trigger, jobDetail, instruction, ct) — the firing is over. This releases a [DisallowConcurrentExecution] job's siblings, and is called even when the job never ran. ReleaseAcquiredTrigger is only for a trigger acquired and never fired.

TimeSpan GetAcquireRetryDelay(int failureCount) is called after repeated AcquireNextTriggers failures. Return between 20 milliseconds and 10 minutes.

Trigger state

Every store stores StoredTriggerState (nine members) and reports TriggerState through one function, so stores agree:

TriggerState reported = TriggerStateResolver.Resolve(stored, isExecuting);
  • Precedence: None > Error > Paused > Executing > Blocked > Complete > Normal. Paused and error win because an operator must act on them; executing beats blocked to tell the running trigger from siblings gated behind it.
  • An unrecognised stored value reads as Waiting, reported as Normal.
  • A missing trigger reads as Deleted, reported as TriggerState.None.
  • StoredTriggerStates.ToStoredValue() / FromStoredValue() map to and from the persisted strings.

Queries

The six paged Query… members are abstract:

  • Order by group, then name, ordinal. Fire instances add fire instance id as a third key, since one trigger can have several in flight.
  • HasMore is exact. Read one row past Take.
  • TotalCount only when asked. Take = 0 with IncludeTotalCount = true skips the row query.

QueryFireInstances covers the cluster if firings are stored durably, otherwise this process. FireInstance.JobKey is null while a firing is only Acquired.

Cluster nodes

QueryClusterNodes(ct) returns ClusterNodes, unpaged:

  • The current node is always first, and the only one with IsCurrentNode = true, whether or not the store has a record of it. The rest follow by instance id, ordinal.
  • A store without membership returns only that node, ClusterNodeState.Alive, with LastCheckInUtc and CheckInInterval null, so callers need not check Clustered first.
  • A store with membership lists every recorded node, including dead ones not yet swept, and decides State with the same predicate as its failover pass. Overdue is a missed check-in; Failed is when the store takes over the node's work.

Bulk members

Key-set members such as PauseJobs(keys), ResumeTriggers(keys) and DeleteJobs(keys) default to looping the single-key member (one lock or round trip per key). Override those your store can do in one pass.

Storing a trigger paused

From 4.4, AddTriggerOptions and ScheduleJobOptions carry Paused, PauseReason and PauseRequestedBy.

bool SupportsStoringPausedWhat the scheduler does
false, the defaultHands the store Replace alone, then pauses each trigger with PauseTriggerWith. A due trigger can fire in between
trueHands the store the options; the store stores each trigger paused

To answer true, store each trigger paused in the same operation as the rest of AddTrigger or ScheduleJobs:

  • Paused-blocked while a [DisallowConcurrentExecution] job of it is running.
  • With its record, cut and stamped as PauseTriggerWith writes one. Over a replaced trigger, it replaces the old record.
  • A continuation stays awaiting; the scheduler refuses one before it reaches the store.

DelegatingJobStore answers what its inner store answers.

Two properties that are answers, not settings

bool Clustered and bool SupportsPersistence are read-only: they describe what the store is. A store that cannot cluster returns false.

Narrowing what a node picks up

TriggerAcquisitionRequest is a record, so a DelegatingJobStore can rewrite it with with, for example to take at most five triggers at a time:

public sealed class BudgetedJobStore(IJobStore inner, int nodeBudget) : DelegatingJobStore(inner)
{
    public override ValueTask<List<IOperableTrigger>> AcquireNextTriggers(
        TriggerAcquisitionRequest request,
        CancellationToken cancellationToken = default)
    {
        return base.AcquireNextTriggers(Narrow(request), cancellationToken);
    }

    // The same request, rewritten the same way, so the inner store still fires what is due on acquisition.
    public override ValueTask<TriggerAcquisitionResult> AcquireNextTriggersAndFireDue(
        TriggerAcquisitionRequest request,
        CancellationToken cancellationToken = default)
    {
        return InnerJobStore.AcquireNextTriggersAndFireDue(Narrow(request), cancellationToken);
    }

    private TriggerAcquisitionRequest Narrow(TriggerAcquisitionRequest request)
    {
        return request with { MaxCount = Math.Min(request.MaxCount, nodeBudget) };
    }
}

The MaxCount rule

Lower MaxCount, never raise it. Lock-free or locked acquisition is chosen from the original request, so a raised count is caught only afterwards and the surplus released and retried: a silent performance cost, not corruption.

The decorator runs on every acquisition attempt, so time-based rules (like the maintenance window below) take effect without a restart.

Excluding job types from acquisition

Set ExcludedJobTypeNames to decline whole job types. Every shipped store honours it before rows count against MaxCount: the ADO.NET store in SQL, RAMJobStore by skipping candidates.

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);
    }
}
  • Use JobType.FullName (Namespace.TypeName, AssemblyName), the string in TriggerAcquireResult.JobTypeName and JOB_CLASS_NAME. Type.FullName lacks the assembly and never matches.
  • Matching is exact, with no prefix or wildcard. In SQL it follows the JOB_CLASS_NAME collation; in memory it is ordinal.
  • Rows written by Quartz 2.x or 3.x may use an older spelling, which is never rewritten and will not match.
  • At most 1000 non-blank entries (Oracle's IN list limit), checked when the request is built.

The ADO.NET store is not a base class

AdoJobStoreBase, LocalTransactionJobStore and ExternalTransactionJobStore are internal. quartz.jobStore.type still names them, so configuration files need no change. In code, UsePersistentStore() builds the local-transaction store and store.UseAmbientTransactions() inside its callback the other.

Deriving never worked well: each protected member below the two abstract ones is the connection-taking twin of a public member (AddJob(conn, …) beside AddJob(job, …)); the public one locks and the twin does the work, so overriding one changes half an operation.

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; if the shipped stores nearly fit, open an issue

Rebuilding jobs and triggers

A store that reads data back reconstructs IJobDetail and IOperableTrigger:

  • Jobs go through JobBuilder, the only supported path (JobDetailImpl is internal), as in the ADO store.
  • Triggers can be constructed directly. Quartz.Impl.Triggers.*TriggerImpl and the abstract TriggerBase are public, and all five are subclassable. Pair a subclassed trigger with a serializer derived from its public, unsealed built-in serializer; BuiltInTriggerSerializerDerivationTest guards both, 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; only this exercises the fire cycle's ordering.
  • The contract: ordering, HasMore and the Take = 0 count are testable on the store alone.
  • Fault handling: wrap your store in a DelegatingJobStore to 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: 10/6/26, 2:13 PM
Contributors: Marko Lahma, Claude Opus 5.5
Prev
Extending Quartz: what is open, what is closed, and how to ask
Next
A Driver Delegate for a New Database