NCache as Quartz.NET Job Store
NCache provides a Quartz.NET integration through the NCache.OSS.Quartz package. It implements the Quartz.NET IJobStore interface and persists jobs, triggers, calendars, firing records, paused groups, and scheduler cluster information in NCache.
Using NCache as the Quartz.NET job store enables multiple application instances to share scheduling state while continuing to use Quartz.NET's standard IScheduler APIs.
Prerequisites
- Install the following NuGet package in your application:
- NCache Open Source: NCache.OSS.Quartz
- Include the following namespaces in Program.cs, as appropriate:
NCache.OSS.QuartzNCache.OSS.Quartz.ExtensionsQuartz
- Ensure the application references the compatible Quartz hosting integration when using
AddQuartzHostedService. - Ensure the target cache is created and running in the NCache cluster.
- Ensure the application can connect to the target NCache cache.
Configure NCache as Quartz.NET Job Store
NCache supports three configuration approaches through the Quartz builder: configuration binding, an options object, and a short overload accepting the cache name.
Method 1: Using appsettings.json
Add the Quartz:NCache section to appsettings.json:
{
"Quartz": {
"NCache": {
"CacheName": "defaultCache",
"SchedulerName": "orders-scheduler",
"InstanceId": "node-1",
"KeyPrefix": "ncache:quartz:",
"MisfireThreshold": "00:01:00",
"ClusterCheckinInterval": "00:00:10",
"ClusterCheckinMisfireThreshold": "00:00:07.5",
"LockExpiration": "00:00:30",
"RepairRegistriesOnStart": true
}
}
}
Register the NCache job store in Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddQuartz(q =>
{
q.UseNCacheStore(builder.Configuration);
});
builder.Services.AddQuartzHostedService(options =>
{
options.WaitForJobsToComplete = true;
});
var app = builder.Build();
app.Run();
By default, UseNCacheStore(builder.Configuration) reads the Quartz:NCache section. To use a different section, pass its name:
builder.Services.AddQuartz(q =>
{
q.UseNCacheStore(builder.Configuration, "MyApplication:QuartzStore");
});
UseNCacheStore registers a job-store factory and does not start the scheduler. The factory opens the NCache connection through CacheManager.GetCache when Quartz creates the job store. This can happen before IScheduler.Start() is called.
Method 2: Using NCacheJobStoreOptions
Configure the job store directly in code:
builder.Services.AddQuartz(q =>
{
q.UseNCacheStore(new NCacheJobStoreOptions
{
CacheName = "defaultCache",
SchedulerName = "orders-scheduler",
InstanceId = Environment.MachineName,
MisfireThreshold = TimeSpan.FromSeconds(30),
ClusterCheckinInterval = TimeSpan.FromSeconds(5),
LockExpiration = TimeSpan.FromSeconds(30)
});
q.UseDefaultThreadPool(10);
});
builder.Services.AddQuartzHostedService(options =>
{
options.WaitForJobsToComplete = true;
});
Use a unique InstanceId for each running scheduler process; Environment.MachineName is suitable only when there is one participating process per machine.
Method 3: Using Cache and Scheduler Names
For a shorter setup, supply the cache name, with optional scheduler and instance identifiers:
builder.Services.AddQuartz(q =>
{
q.UseNCacheStore("defaultCache", "orders-scheduler", "node-1");
});
Only the cache name is required:
q.UseNCacheStore("defaultCache");
When InstanceId is missing, blank, or AUTO, Quartz automatically generates an instance ID.
NCacheJobStoreOptions
All options except CacheName are optional. An explicitly supplied MisfireThreshold must be at least 1 ms, ClusterCheckinInterval must be at least 100 ms, ClusterCheckinMisfireThreshold must be nonnegative, LockAcquireTimeout must be positive, and LockExpiration must exceed 100 ms. Keep cluster-related values consistent across scheduler instances. The following table lists the //NCache job store options and their effective default values.
| Property | Default | Description |
|---|---|---|
CacheName |
Required | Name of the running NCache cache used to persist Quartz scheduling data. |
SchedulerName |
Registered scheduler name or Quartz default | Logical scheduler name. Must match across nodes participating in the same scheduler. |
InstanceId |
Generated by Quartz | Unique identifier of the running scheduler process. Blank or AUTO causes automatic generation. |
KeyPrefix |
ncache:quartz: |
Prefix applied to the job store's NCache keys. Must match across scheduler nodes. |
LockExpiration |
30 seconds | Lifetime of an internal distributed lock before it expires. Must exceed 100 ms. |
LockAcquireTimeout |
null |
Maximum wait for a distributed lock. null means wait until acquired or cancelled. |
MisfireThreshold |
60 seconds | Default lateness threshold for treating triggers as misfired. Individual triggers may override it. |
ClusterCheckinInterval |
10 seconds | Interval between scheduler check-ins and maintenance passes. |
ClusterCheckinMisfireThreshold |
7.5 seconds | Additional allowance after a missed check-in before a scheduler is stale. |
RepairRegistriesOnStart |
true |
Rebuilds stored registries at startup. The next-fire-time index is rebuilt on startup regardless. |
ConfigureSerializer |
null |
Registers custom serializers and type resolvers programmatically. Cannot be configured through appsettings.json. |
Configure a Quartz Scheduler Cluster
Each scheduler node uses the same NCache cache, logical scheduler name, and key prefix but a different instance identifier.
Node 1 — appsettings.json
{
"Quartz": {
"NCache": {
"CacheName": "defaultCache",
"SchedulerName": "orders-scheduler",
"InstanceId": "node-1",
"KeyPrefix": "ncache:quartz:"
}
}
}
Node 2 — appsettings.json
{
"Quartz": {
"NCache": {
"CacheName": "defaultCache",
"SchedulerName": "orders-scheduler",
"InstanceId": "node-2",
"KeyPrefix": "ncache:quartz:"
}
}
}
For one logical scheduler, use the same CacheName, SchedulerName, KeyPrefix, MisfireThreshold, ClusterCheckinInterval, and ClusterCheckinMisfireThreshold on all nodes. Each running process needs its own InstanceId.
A stable instance ID is useful across restarts: the node can recover its own leftover work immediately at startup. AUTO ensures uniqueness but does not preserve the same ID across restarts.
Configure Quartz Scheduler Settings
Quartz uses the thread-pool size and idle-wait settings to control scheduler behavior. The NCache job store applies the acquisition request's batch size, time window, execution-group routing, and execution limits. Cluster-scoped execution limits count acquired and executing firing records across all scheduler nodes. Configure them through the Quartz builder:
builder.Services.AddQuartz(q =>
{
q.UseNCacheStore(builder.Configuration);
q.UseDefaultThreadPool(10);
q.RefuseExecutionGroups("reports");
q.ConfigureScheduler(options =>
{
options.MaxBatchSize = 5;
options.IdleWaitTime = TimeSpan.FromSeconds(1);
});
q.UseExecutionLimits(limits => limits
.ForGroup("serial", 1, ExecutionLimitScope.Cluster)
.ForGroup("emails", 3, ExecutionLimitScope.Node));
});
Custom Serialization
Built-in Quartz calendar types and the supported standard trigger types do not need additional serializers.
To persist application-defined calendar and custom trigger types, register the appropriate Quartz serializers on every scheduler node. JobDataMap supports built-in types and application-defined types registered through SystemTextJsonSerializerRegistry.AddTypeInfoResolver. In Quartz.NET 4.6.2, declared types containing list-like collection members (List<T>, arrays, HashSet<T>, or IEnumerable<T>) are rejected, while dictionaries and nested objects are supported. Custom values use default System.Text.Json serialization, so custom converters may not be supported. Ensure required types are available on all scheduler nodes. Unsupported values can cause a JobPersistenceException.
Logging
The integration logs through Microsoft.Extensions.Logging using the category NCache.OSS.Quartz.NCacheJobStore. Quartz.NET's scheduler logging remains separate under the Quartz categories.
Configure logging in appsettings.json:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"NCache.OSS.Quartz": "Information",
"Quartz": "Information"
}
}
}
Set NCache.OSS.Quartz to Debug when investigating trigger acquisition and distributed-lock contention. The job store logs failures, retries, and recovery activity at appropriate levels; it does not log a message for every successful job at Information level.
Failure Recovery
Every active scheduler node periodically stores a check-in record in NCache. A node is considered stale when the time since its last check-in exceeds its recorded check-in interval plus ClusterCheckinMisfireThreshold. Recovery can include:
- Returning acquired but unfired triggers to an eligible state.
- Creating recovery triggers for jobs configured with Quartz's
RequestRecovery(). - Releasing trigger state blocked by executions on a failed node, subject to a grace period for non-concurrent jobs.
- Repairing stale acquisitions and rebuilding job store registries during startup.
With the default 10-second check-in interval and 7.5-second additional threshold, a node is considered stale once more than 17.5 seconds have elapsed since its last check-in. Recovery occurs during a subsequent maintenance pass.
The NCache job store also releases stale trigger acquisitions when their age exceeds StaleAcquiredTriggerThreshold, even if the owning scheduler instance is active. By default, this threshold is the greater of twice MisfireThreshold and two minutes.
Troubleshooting
| Problem | Possible Cause | Suggested Action |
|---|---|---|
| Scheduler fails to start with a cache exception | The cache is missing, stopped, or unreachable. | Ensure the cache is running and client connectivity is configured. |
CacheName configuration error |
The required cache name is missing or empty. | Supply CacheName in the selected configuration approach. |
| Scheduler name configuration error | A named Quartz registration does not match SchedulerName. |
Use the same name for the registration and job store configuration. |
| Duplicate or unexpectedly recovered jobs | More than one running process has the same InstanceId. |
Assign a unique ID to each running scheduler instance. |
JobPersistenceException reports an expired lock |
The lock section ran longer than LockExpiration, or lock ownership was lost. |
Investigate delays and consider increasing LockExpiration for the workload. |
Operations fail with a lock TimeoutException |
LockAcquireTimeout is too short under contention. |
Increase the timeout or leave it unset when waiting is acceptable. |
A trigger enters the Error state |
Its job class cannot be loaded, or its referenced job is missing. | Restore the required job type or state, then use Quartz's error-state reset operation where appropriate. |
Important Considerations
- Start the NCache cache before starting the Quartz scheduler.
- Keep
CacheName,SchedulerName, andKeyPrefixconsistent across one scheduler cluster. - Assign a unique
InstanceIdto each active process. - Keep cluster timing options consistent across nodes.
- Leave
RepairRegistriesOnStartenabled unless startup scan cost requires a deliberate tradeoff. - Keep
LockAcquireTimeoutunset unless the application needs bounded lock waits. - Use
WaitForJobsToComplete = truefor normal graceful shutdown. - Capacity-plan the cache for job, trigger, and index data. The store marks its entries as
NotRemovable; eviction-policy behavior still depends on the NCache cache configuration.
Limitations
UpdateTriggerDetailsis not supported; useIScheduler.RescheduleJob, which replaces a trigger.- Custom triggers require a compatible Quartz trigger serializer on every node.
- Quartz's cross-node interruption (
RequestInterrupt) is not implemented by this store. CancelPreviouscannot interrupt a firing on another scheduler node. Once the policy is settled, a new firing waits for an earlier remote firing of the same trigger to finish. When an existing trigger is changed toCancelPreviouswhile an earlier firing remains active, the transition can allow another firing alongside it.- Scheduling and firing require access to NCache. If the cache becomes unavailable, store operations cannot continue normally.
- New triggers scheduled by a different node are discovered on a subsequent acquisition pass rather than via direct node-to-node notifications.