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

A clustered ADO job store serializes its work with a lock so two nodes cannot acquire the same trigger. By default it is a row in QRTZ_LOCKS. Implement ILockHandler to use anything that grants one holder at a time: Redis, ZooKeeper, a cloud lease.

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 defaults to empty; skip it if your locks are not keyed by scheduler identity.

There are exactly two locks

SchedulerLock has two members, so no caller can invent a lock that protects nothing:

MemberGuardsStored as
TriggerAccessevery change to jobs, triggers and calendars, trigger acquisition and firing, and the completions listed belowTRIGGER_ACCESS
StateAccesscluster check-in and failed-node recovery, in their own transaction (no deadlock with trigger work)STATE_ACCESS

A firing whose job allows concurrent execution and whose trigger asks for nothing at the end (NoInstruction or DeleteTrigger) completes with no lock: it writes only its own rows. A handler sees a TriggerAccess acquisition for a completion only when it has to read what the lock keeps still:

CompletionWhy it takes the lock
A [DisallowConcurrentExecution] jobunblocks the job's other triggers
RetryTrigger, SetTrigger*, SetAllJobTriggers*consults paused groups or writes other rows
A BufferOne or CancelPrevious firinglets go of a held trigger
A Skip firing on its last occurrencemay delete a trigger a skip finalized
A trigger of a non-durable job that the completion deletescounts the job's triggers
A firing with continuations awaiting itsettles them; the lock is taken after a first lock-free read finds them
SQLiteevery operation

Warning

The enum-to-string mapping is internal. A handler that needs the stored names (for key compatibility with the row-lock handler, or across a rolling upgrade) declares its own constants, as RedisLockHandler does so a mixed-version cluster contends for the same Redis key.

Re-entry returns false

The most important rule: AcquireLock called again with the same requestorId and lockKind returns false and takes no second lock.

false is not an error. The store releases only a lock its own call took, so a nested operation re-enters without re-locking or releasing early. Returning true would let the inner operation release the outer one's lock.

That is for a handler that records whether a requestor holds a lock (the row-lock handlers, InProcessLockHandler). A handler that counts holds, like the shipped SqliteLockHandler, may return true, because the inner release only decrements. Either way: the caller releases exactly when it was told true, and never otherwise.

ReleaseLock from a non-owner should warn, not throw.

And false means nothing else

An acquire that did not take the lock throws: LockException when refused, OperationCanceledException when the token fired.

Caution

Never return false on cancellation. The store reads it as already held: it runs the operation unlocked and releases nothing. Do not rely on the next step (with RequiresConnection = false, a connection open on the same token) throwing first; that is statement order, not a guarantee.

A handler that gives up leaves nothing behind: an abandoned wait 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) is released before the exception escapes.

Deriving from DbLockHandler

For a database row lock, DbLockHandler handles ownership, re-entry and prefix substitution, and leaves one method:

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

Take the row lock and return normally on success, or throw; ownership is recorded after it returns. Both statements arrive prefix-expanded; the insert covers a missing row. If a failure ended the transaction (conn.Transaction.Connection is null — a deadlock victim, a write conflict on a memory-optimized row), throw rather than retry: SQL Server runs a retried statement outside any transaction, and the store retries the whole operation on a fresh one. Issue the statements through:

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

No overload takes a provider-specific type or size: lock statements bind two strings.

Shipped implementations, both public and unsealed, waiting on the TimeProvider so retries are testable:

  • UpdateRowLockHandler — UPDATE {0}LOCKS SET LOCK_NAME = LOCK_NAME WHERE SCHED_NAME = @schedulerName AND LOCK_NAME = @lockName, retried RetryCount times (protected virtual, 2 by default) with RetryPeriod between attempts, inserting the row if none was updated. SqlServerMemoryOptimizedUpdateRowLockHandler adds the WITH (SNAPSHOT) hint and raises the retry count to 5; it is the handler for tables_sqlServerMOT.sql, whose memory-optimized QRTZ_LOCKS refuses the UPDLOCK,ROWLOCK hints the store's own SQL Server handler locks with.
  • SelectForUpdateLockHandler — SELECT * FROM {0}LOCKS … FOR UPDATE, with PostgreSqlSelectForUpdateLockHandler as its dialect variant.

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

Implementing ILockHandler directly

For a lock outside the database, implement the interface with RequiresConnection false:

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 lets the store open its connection only after the lock is taken, which is the point of an external lock.

Warning

RequiresConnection = false with AcceptEnlistedTransactions logs a startup warning: the lock is released when Quartz's work ends, before the application commits its ambient transaction, so it does not cover the window it should.

LockHandlerContext

The job store calls Initialize once, after choosing the handler and before schema validation, on both construction paths (otherwise a container-supplied handler would query QRTZ_LOCKS with a null scheduler name):

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
  • CommandTimeout bounds a node stuck on QRTZ_LOCKS behind a peer that stopped without releasing the row.
  • LockWaitWarningThreshold: a DbLockHandler subclass logs warning 3716 once per slow acquisition, unasked; your own handler may ignore it or report its own way.
  • The store times every acquisition on quartz.jobstore.lock.wait.duration; handlers owe nothing for it.

Registering it

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

UseLockHandler(Func<IServiceProvider, ILockHandler>) is for a handler needing values rather than services; it registers under the scheduler's key, unlike a registration on Services. Quartz.Extensions.Redis uses this same public overload:

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

The legacy key is quartz.jobStore.lockHandler.type. Its 3.x sub-keys .tablePrefix and .schedName (from ITablePrefixAware) are rejected as obsolete, since Initialize supplies both, and so is .schedulerName, the key the 4.x property name suggests.

A handler is always used

AdoJobStoreOptions.UseDbLocks picks which handler the store builds, not whether it locks:

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

A non-clustered scheduler locks in memory, which is correct for a single node.

The store logs the handler it ends up with at startup: event 3006 or 3007 for one it built, 3048 for one you registered. On SQL Server's memory-optimized schema, register UseLockHandler<SqlServerMemoryOptimizedUpdateRowLockHandler>(); the handler the store would build fails its first lock with The table option 'rowlock' is not supported with memory optimized tables.

Testing one

No scheduler needed:

  • Call AcquireLock twice with the same requestorId; assert the second returns false.
  • Acquire with an already-fired token; assert OperationCanceledException, not false. Then acquire from another requestorId and assert it succeeds, catching a lock left held by the abandoned attempt.
  • Pass a FakeTimeProvider through LockHandlerContext and advance it to drive the retry loop.

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: 10/6/26, 2:13 PM
Contributors: Marko Lahma, Claude Opus 5.5
Prev
Persisting a Custom Trigger Type