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 Lock Handler of Your Own

A clustered ADO job store serializes its work with a lock, so that two nodes cannot acquire the same trigger. By default that lock is a row in QRTZ_LOCKS. ILockHandler is the seam for making it something else — Redis, ZooKeeper, a cloud lease, anything that can grant one holder at a time.

The contract

public interface ILockHandler
{
    bool RequiresConnection { get; }

    void Initialize(LockHandlerContext context) { }

    ValueTask<bool> AcquireLock(Guid requestorId, ConnectionAndTransactionHolder? conn,
        SchedulerLock lockKind, CancellationToken cancellationToken = default);

    ValueTask ReleaseLock(Guid requestorId, SchedulerLock lockKind,
        CancellationToken cancellationToken = default);
}

Initialize has a default (empty) implementation, so a handler that does not key its locks by scheduler identity can skip it.

There are exactly two locks

SchedulerLock is an enum with two members, and there have only ever been two:

MemberGuardsStored as
TriggerAccessevery change to jobs, triggers and calendars, and trigger acquisitionTRIGGER_ACCESS
StateAccesscluster check-in and failed-node recovery, on their own transaction so they cannot deadlock against trigger workSTATE_ACCESS

Saying so in the type means a caller cannot invent a third lock that silently protects nothing.

Warning

The enum-to-string mapping is internal. A handler in your own assembly that needs the stored names — for key compatibility with the row-lock handler, or across a rolling upgrade — declares its own constants. RedisLockHandler does exactly that, with the comment that the Redis key keeps the stored lock names so that a mixed-version cluster keeps contending for the same key.

Re-entry returns false

The single most important rule: AcquireLock called again with the same requestorId and the same lockKind must return false, and must not take a second lock.

That is not an error signal. The store stores the result and releases the lock only when it was the call that took it, so a nested operation on the same caller re-enters without re-locking and without prematurely releasing. Returning true from a re-entrant call means the inner operation releases the lock the outer one is still relying on.

The rule as stated is for a handler that records whether a requestor holds a lock, which is what the row-lock handlers and InProcessLockHandler do. A handler that instead counts its holds may answer true to a re-entrant acquire, because there the inner release is the one that decrements rather than the one that frees; the shipped SqliteLockHandler is such a handler. Either way the invariant the store depends on is the same one: the caller releases exactly when it was told true, and never otherwise.

ReleaseLock from a non-owner should warn, not throw — that is what the shipped handlers do.

And false means nothing else

The other half of that rule: an acquire that did not take the lock throws. LockException when the lock was refused, OperationCanceledException when the token fired.

Caution

Answering false on cancellation is not a gentler way of saying "I got nothing". The store reads false as already held, do not release, so it goes on to run the operation with no lock and releases nothing on the way out. Do not lean on the store's ordering to save you either: a handler with RequiresConnection = false is followed immediately by a connection open on the same token, which would throw first — statement ordering, not a guarantee.

A handler that gives up also leaves nothing behind: a wait abandoned partway must not consume a handover meant for the next waiter, and anything taken before the failure — a local gate in front of a remote lock, say — is released before the exception escapes.

Deriving from DbLockHandler

When the lock is a database row, DbLockHandler does the plumbing — ownership tracking, re-entry, prefix substitution — and leaves one method:

protected abstract ValueTask ExecuteSql(
    Guid requestorId,
    ConnectionAndTransactionHolder conn,
    string lockName,
    string expandedSql,
    string expandedInsertSql,
    CancellationToken cancellationToken = default);

It must take the row lock and return normally on success, or throw on failure; the base only records ownership after it returns. Both statements arrive already prefix-expanded, and the insert is there for the missing-row case.

Two protected helpers are what you issue it through:

protected DbCommand PrepareCommand(ConnectionAndTransactionHolder conn, string commandText);
protected void AddCommandParameter(DbCommand command, string paramName, object? paramValue);

There is no overload taking a provider-specific data type or a size, because a lock statement binds a scheduler name and a lock name and both are strings.

Two shipped implementations to read:

  • UpdateRowLockHandler — UPDATE {0}LOCKS SET LOCK_NAME = LOCK_NAME WHERE SCHED_NAME = @schedulerName AND LOCK_NAME = @lockName, retried RetryCount times (a protected virtual property, 2 by default) with RetryPeriod between attempts, inserting the row if the update affected none. SqlServerMemoryOptimizedUpdateRowLockHandler is a two-line subclass that raises the retry count to 5.
  • SelectForUpdateLockHandler — SELECT * FROM {0}LOCKS … FOR UPDATE, with PostgreSqlSelectForUpdateLockHandler as its dialect variant.

Both are public and unsealed. Both wait on the TimeProvider between attempts rather than on wall time, so their retry behaviour is testable.

DbLockHandler fixes RequiresConnection to true, so its conn is never null.

Implementing ILockHandler directly

When the lock does not live in the database, implement the interface and answer false to RequiresConnection:

public sealed class LeaseLockHandler : ILockHandler
{
    private string schedulerName = "";

    public bool RequiresConnection => false;

    public void Initialize(LockHandlerContext context) => schedulerName = context.SchedulerName;

    public async ValueTask<bool> AcquireLock(
        Guid requestorId,
        ConnectionAndTransactionHolder? conn,
        SchedulerLock lockKind,
        CancellationToken cancellationToken = default)
    {
        // ... acquire, honouring the re-entry rule ...
        return true;
    }

    public ValueTask ReleaseLock(
        Guid requestorId,
        SchedulerLock lockKind,
        CancellationToken cancellationToken = default)
    {
        // ...
        return default;
    }
}

RequiresConnection = false is not cosmetic: it tells the store it can delay opening a database connection until after the lock has been taken, which is the whole efficiency argument for an external lock.

Warning

RequiresConnection = false combined with AcceptEnlistedTransactions produces a startup warning. An in-process or external lock is released as soon as Quartz's work is done, which is before the application commits its ambient transaction — so the window the lock was supposed to protect is not the window it covers.

LockHandlerContext

Initialize is called once, by the job store, after it has decided which handler to use and before schema validation:

Member
SchedulerName (required)the scheduler whose data the lock protects
InstanceId (required)this node
TablePrefix (required)ignored by a handler that does not lock in the database
TimeProviderwait on this rather than on wall time, so retry behaviour is testable
CommandTimeoutfrom AdoJobStoreOptions.CommandTimeout
LockWaitWarningThresholdfrom AdoJobStoreOptions.LockWaitWarningThreshold; null in a context built by hand

The store calls it on both construction paths, and that is why it exists: a handler the container supplied would otherwise query QRTZ_LOCKS with a null scheduler name, whatever the store is actually configured with.

CommandTimeout earns its keep here specifically. A node waiting on QRTZ_LOCKS behind a peer that stopped without releasing the row cannot make progress until the statement gives up.

LockWaitWarningThreshold is the other half of that: how long one acquisition may go on before it is worth saying so. DbLockHandler acts on it for you — a handler deriving from it logs warning 3716 once per slow acquisition without writing a line — and a handler of its own making is free to ignore it or to report the wait its own way. Either way the store times every acquisition on quartz.jobstore.lock.wait.duration, so a handler owes nothing for the metric.

Registering it

builder.Services.AddQuartz(q =>
{
    q.UsePersistentStore(s =>
    {
        s.UseLockHandler<LeaseLockHandler>();
        s.UseSqlServer(connectionString);
        s.UseClustering();
    });
});

There is a factory overload, UseLockHandler(Func<IServiceProvider, ILockHandler>), for a handler that needs values rather than services — it registers under the scheduler's own key, which registering against Services directly would not. Quartz.Extensions.Redis uses exactly that public overload; nothing about it is privileged:

s.UseRedisLockHandler(o =>
{
    o.RedisConfiguration = "localhost:6379";
    o.KeyPrefix = "quartz:";
    o.LockTimeToLive = TimeSpan.FromSeconds(30);
});

The legacy key is quartz.jobStore.lockHandler.type. Its .tablePrefix and .schedName sub-keys — 3.x's spelling, from the ITablePrefixAware properties they wrote — are rejected as obsolete, because Initialize supplies both. .schedulerName is rejected with the same advice, since that is the key the 4.x property name suggests.

A handler is always used

AdoJobStoreOptions.UseDbLocks selects which handler the store builds for itself, not whether locking happens:

SituationHandler
You registered oneyours, and SelectWithLockSql is ignored with a warning
UseDbLocks = true (forced on by clustering and by AcceptEnlistedTransactions)SelectForUpdateLockHandler, or the PostgreSQL variant
OtherwiseInProcessLockHandler — an in-process monitor

So a non-clustered scheduler still locks; it just locks in memory, which is correct when it is the only node.

Testing one

The re-entry rule, the cancellation rule and the retry behaviour are the three things worth a test, and none of them needs a scheduler:

  • Call AcquireLock twice with the same requestorId and assert the second returns false.
  • Acquire with a token that has already fired, and assert an OperationCanceledException rather than a false. Then acquire again from another requestorId and assert it is served, which is how a lock left held by the abandoned attempt shows up.
  • Give the handler a FakeTimeProvider through LockHandlerContext and advance it to drive the retry loop without the test waiting.

See also

  • Clustering — what the locks are protecting
  • Redis — the shipped external lock handler
  • A Driver Delegate for a New Database — the other ADO seam
Help us by improving this page!
Last Updated: 9/11/26, 7:37 PM
Contributors: Marko Lahma, Claude Fable 5.1
Prev
Persisting a Custom Trigger Type