# Quartz.NET Clustering: Run Each Job Once Across Many Servers

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-31
Canonical: https://cronradar.com/blog/quartz-net-clustering

> Set up Quartz.NET clustering with a shared AdoJobStore so each job runs once across instances, and learn how failover, recovery, and lock rows behave.

**To run each Quartz.NET job once across several instances, point every instance at the same database through a clustered `AdoJobStore`: set `quartz.jobStore.clustered = true`, give every node the same `quartz.scheduler.instanceName`, and give each node a unique `quartz.scheduler.instanceId`, which `AUTO` generates.** The default `RAMJobStore` keeps a private copy of the schedule in each process, so three replicas fire every trigger three times. A clustered store makes the nodes claim each firing through rows in shared tables, so ["each firing runs on exactly one node"](https://www.quartz-scheduler.net/documentation/quartz-3.x/configuration/reference.html#clustering), and when a node dies, the others release its work and re-run the jobs that request recovery.

Everything below was checked against the source and documentation of [Quartz.NET 3.22.7](https://github.com/quartznet/quartznet/releases/tag/v3.22.7), the current 3.x release on October 25, 2026. Quartz.NET 4.x clusters the same way, but a 3.x database needs the [4.0 schema upgrade](https://github.com/quartznet/quartznet/blob/v3.22.7/database/README.md#upgrading-3x--4x-is-mandatory-and-the-scripts-are-on-main) first.

## Why Every Instance Runs Every Job

`AddQuartz()` defaults to `UseInMemoryStore()` ([Microsoft DI integration](https://www.quartz-scheduler.net/documentation/quartz-3.x/packages/microsoft-di-integration.html)). `RAMJobStore` holds jobs and triggers in process memory, so two processes are two schedulers with two schedules, and clustering "works only with the AdoJobStore" ([advanced features](https://www.quartz-scheduler.net/documentation/quartz-3.x/tutorial/advanced-enterprise-features.html)).

A shared database doesn't fix it alone. Three setups still run a job on every instance:

- **A different `instanceName` per node**, such as one built from the machine name. "A name per node makes each node an independent scheduler that fires every trigger" ([best practices](https://www.quartz-scheduler.net/documentation/best-practices.html#one-name-per-cluster-one-id-per-node)).
- **Jobs or triggers without an explicit identity.** Quartz.NET 3.22.7 warns that a generated key is stored, "so each start adds another copy, and every copy of a trigger fires" ([ContainerConfigurationProcessor.cs](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz.Extensions.DependencyInjection/ContainerConfigurationProcessor.cs#L64-L69)).
- **One node without `clustered = true`** against the shared tables, which the docs say risks "serious data corruption."

## Create the Tables Once, Before Any Node Starts

Quartz.NET "does not create or migrate its schema automatically" ([database README](https://github.com/quartznet/quartznet/blob/v3.22.7/database/README.md)). Run your database's script from [`database/tables`](https://github.com/quartznet/quartznet/tree/v3.22.7/database/tables) at the tag you deploy:

| Database         | Script                    | Store method                      | Driver package                   |
| ---------------- | ------------------------- | --------------------------------- | -------------------------------- |
| PostgreSQL       | `tables_postgres.sql`     | `UsePostgres`                     | `Npgsql`                         |
| SQL Server 2016+ | `tables_sqlServer.sql`    | `UseSqlServer`                    | `Microsoft.Data.SqlClient`       |
| MySQL, MariaDB   | `tables_mysql_innodb.sql` | `UseMySql` or `UseMySqlConnector` | `MySql.Data` or `MySqlConnector` |
| Oracle           | `tables_oracle.sql`       | `UseOracle`                       | `Oracle.ManagedDataAccess`       |

Quartz loads the driver by type name, such as `Npgsql.NpgsqlConnection, Npgsql` ([dbproviders.netstandard.properties](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Impl/AdoJobStore/Common/dbproviders.netstandard.properties#L48)), so your app references the package. Before running a script:

- **The PostgreSQL and SQL Server scripts drop existing Quartz tables first.** Set `DropDb` (`@DropDb`) to 0 before running one against a database that holds Quartz data.
- **Tables start with `QRTZ_`.** If you change that, set `quartz.jobStore.tablePrefix` to match; on SQL Server it can include a schema, such as `[foo].QRTZ_` ([reference](https://www.quartz-scheduler.net/documentation/quartz-3.x/configuration/reference.html#quartz-jobstore-tableprefix)).
- **Existing databases upgrade through `database/migrations/`.** The 3.17 to 3.19 columns are optional on 3.x, which turns the matching features off with a warning when they're missing.

## Configure Every Node Identically

```bash
dotnet add package Quartz.Extensions.Hosting
dotnet add package Quartz.Serialization.SystemTextJson
dotnet add package Npgsql
```

Only the thread pool size and `instanceId` may differ between nodes ([reference](https://www.quartz-scheduler.net/documentation/quartz-3.x/configuration/reference.html#clustering)):

```csharp
// Program.cs: Quartz.NET 3.22.7 with PostgreSQL
using Quartz;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<InvoiceRun>();         // your own service
builder.Services.AddScoped<NightlyInvoicesJob>(); // lets ValidateOnBuild check the constructor

builder.Services.AddQuartz(q =>
{
    q.SchedulerName = "billing"; // identical on every node of this cluster
    q.SchedulerId = "AUTO";      // unique per node, generated at each start
    q.UseDefaultThreadPool(maxConcurrency: 10);

    q.UsePersistentStore(store =>
    {
        store.UseProperties = true; // job data values are strings
        store.UsePostgres(builder.Configuration.GetConnectionString("Quartz")!);
        store.UseSystemTextJsonSerializer();
        store.UseClustering(cluster =>
        {
            cluster.CheckinInterval = TimeSpan.FromSeconds(7.5);
            // Peers write a node off after 7.5 + 30 seconds without a check-in.
            cluster.CheckinMisfireThreshold = TimeSpan.FromSeconds(30);
        });
    });

    var invoices = new JobKey("nightly-invoices", "billing");
    q.AddJob<NightlyInvoicesJob>(invoices, job => job.RequestRecovery());
    q.AddTrigger(trigger => trigger
        .ForJob(invoices)
        .WithIdentity("nightly-invoices", "billing")
        .WithCronSchedule("0 0 2 * * ?", cron => cron.InTimeZone(TimeZoneInfo.Utc)));
});

builder.Services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);

var app = builder.Build();
app.Run();
```

The settings, with the flat property names from the [configuration reference](https://www.quartz-scheduler.net/documentation/quartz-3.x/configuration/reference.html):

| Property                                         | In code                                                            | Set it to                                                                                                                                                                              |
| ------------------------------------------------ | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `quartz.scheduler.instanceName`                  | `SchedulerName`                                                    | One name per cluster. Default: `QuartzScheduler`.                                                                                                                                      |
| `quartz.scheduler.instanceId`                    | `SchedulerId`                                                      | `AUTO` or a distinct value per node. Default: `NON_CLUSTERED`, shared by every node left at it.                                                                                        |
| `quartz.jobStore.clustered`                      | `UseClustering()`                                                  | `true` on every node.                                                                                                                                                                  |
| `quartz.jobStore.clusterCheckinInterval`         | `CheckinInterval`                                                  | How often a node checks in. Default: 7.5 seconds.                                                                                                                                      |
| `quartz.jobStore.clusterCheckinMisfireThreshold` | `CheckinMisfireThreshold`                                          | Longer than your worst pause. Default: 7.5 seconds.                                                                                                                                    |
| `quartz.jobStore.acquireTriggersWithinLock`      | `store.SetProperty(...)`                                           | `false`, unless `batchTriggerAcquisitionMaxCount` exceeds 1; then `true`, its default there, "to avoid data corruption."                                                               |
| `quartz.jobStore.useProperties`                  | `UseProperties`                                                    | `true`, so job data is stored as strings, not serialized objects.                                                                                                                      |
| `quartz.serializer.type`                         | `UseSystemTextJsonSerializer()` or `UseNewtonsoftJsonSerializer()` | Required: a persistent store won't start without one ([StdSchedulerFactory.cs](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Impl/StdSchedulerFactory.cs#L751-L758)). |

## How Nodes Claim a Firing: Lock Rows and Check-Ins

Unless you configure a lock handler, a clustered store uses database row locks: `PostgreSQLRowLockSemaphore` on PostgreSQL, `StdRowLockSemaphore` elsewhere, with `UPDLOCK, ROWLOCK` on SQL Server ([JobStoreSupport.cs](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Impl/AdoJobStore/JobStoreSupport.cs#L868-L911)). Four tables carry the coordination, each keyed by scheduler name, which is why the name decides membership:

| Table                  | Role in the cluster                                                                                                     |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `QRTZ_LOCKS`           | One row per lock: `TRIGGER_ACCESS` and `STATE_ACCESS`. Inserted on first use, then locked with `SELECT ... FOR UPDATE`. |
| `QRTZ_TRIGGERS`        | Each trigger's state, such as `WAITING`, `ACQUIRED`, `BLOCKED`, or `ERROR`.                                             |
| `QRTZ_FIRED_TRIGGERS`  | One row per acquired or executing firing, naming the instance that owns it.                                             |
| `QRTZ_SCHEDULER_STATE` | One row per node: instance ID, last check-in time, and check-in interval.                                               |

A firing moves through them in three steps:

1. **Acquire.** A node flips a due trigger from `WAITING` to `ACQUIRED` with a conditional update. If another node got there first, the update changes no rows and the node moves on ([JobStoreSupport.cs](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Impl/AdoJobStore/JobStoreSupport.cs#L4520-L4528)). This runs inside `TRIGGER_ACCESS` only with `acquireTriggersWithinLock` or batch acquisition.
2. **Fire.** Holding `TRIGGER_ACCESS`, the node confirms the trigger is still `ACQUIRED`, loads the job, computes the next fire time, and marks its fired-trigger row `EXECUTING`.
3. **Check in.** Every `clusterCheckinInterval`, each node updates its state row. It takes `STATE_ACCESS` only on its first check-in or when a peer has gone stale, and `TRIGGER_ACCESS` only when it has rows to recover ([DoCheckin](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Impl/AdoJobStore/JobStoreSupport.cs#L5535-L5592)).

`TRIGGER_ACCESS` is one lock for the whole cluster, and it "degrades performance beyond about three nodes, depending on your database." Run the scheduler in a few worker replicas rather than every web replica, and the docs suggest splitting thousands of short jobs across distinct schedulers.

Load is shared, not balanced. "Busy schedulers (many triggers) pick it near-randomly; non-busy ones favor the same node," so a few nightly jobs may all land on one node. Two 3.x features steer placement, each needing an optional schema column: [execution groups](https://www.quartz-scheduler.net/documentation/quartz-3.x/tutorial/execution-groups.html) (3.18) cap threads per category on each node, where a limit of `0` forbids the group, and a [preferred node](https://www.quartz-scheduler.net/documentation/quartz-3.x/tutorial/node-affinity.html) (3.19) pins a trigger to an instance ID with automatic failover, which requires a stable ID instead of `AUTO`.

## Failover and RequestsRecovery

A peer writes a node off once its last check-in is older than its check-in interval plus the peer's `clusterCheckinMisfireThreshold`: 15 seconds on the defaults ([CalcFailedIfAfter](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Impl/AdoJobStore/JobStoreSupport.cs#L5734-L5739)). [ClusterRecover](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Impl/AdoJobStore/JobStoreSupport.cs#L5766-L5990) then handles each fired-trigger row the node left:

- **Acquired but not fired:** the trigger returns to `WAITING` and fires normally.
- **Executing, job requests recovery:** a one-shot trigger in the `RECOVERING_JOBS` group runs the job again on any node, with the original trigger's job data.
- **Executing, no recovery:** the row is deleted, the occurrence is lost, and the trigger continues from its next fire time.

"By default a firing runs at most once," and `RequestRecovery()` makes it "at least once" ([best practices](https://www.quartz-scheduler.net/documentation/best-practices.html#what-requestsrecovery-re-runs-and-when)). It isn't a retry: a job that threw has completed its firing. A node restarting with the same instance ID recovers its own leftover rows at its first check-in.

Recovery also runs against a node that is alive but silent. A node at 100% CPU, in a long garbage-collection pause, or on a suspended VM can miss check-ins, and its peers re-run its recovery-requesting jobs while it still runs them ([clocks in a cluster](https://www.quartz-scheduler.net/documentation/best-practices.html#clocks-in-a-cluster)). It then logs `This scheduler instance (...) is still active but was recovered by another instance in the cluster.` Alert on that line and raise `clusterCheckinMisfireThreshold` above your worst pause, at the cost of slower takeover.

So a job that requests recovery must be safe to run twice. Key the work on the occurrence, not `FireInstanceId` or the trigger, because a recovery run gets new ones:

```csharp
using Microsoft.Extensions.Logging;
using Quartz;

[DisallowConcurrentExecution]
public sealed class NightlyInvoicesJob(InvoiceRun invoices, ILogger<NightlyInvoicesJob> logger) : IJob
{
    public async Task Execute(IJobExecutionContext context)
    {
        if (context.Recovering)
            logger.LogWarning("Re-running {JobKey} after its node stopped checking in", context.JobDetail.Key);

        // A recovery run is scheduled for the original firing's time, so it maps to the same date.
        var billingDate = DateOnly.FromDateTime(
            (context.ScheduledFireTimeUtc ?? context.FireTimeUtc).UtcDateTime);

        // Records the date under a unique index and creates invoices in one transaction.
        await invoices.CreateOnceAsync(billingDate, context.CancellationToken);
    }
}
```

`InvoiceRun` stands for your own service. Per [`IJobExecutionContext`](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/IJobExecutionContext.cs#L38-L48), a recovering job "will have been scheduled for the same firing time as the original job."

## DisallowConcurrentExecution and PersistJobDataAfterExecution Across Nodes

`[DisallowConcurrentExecution]` applies per `JobDetail`, not per class ([more about jobs](https://www.quartz-scheduler.net/documentation/quartz-3.x/tutorial/more-about-jobs.html)), and with a persistent store it holds across the cluster. When the job fires, its other triggers move to `BLOCKED`, and acquisition and firing both check `QRTZ_FIRED_TRIGGERS` for an execution elsewhere; the fire path logs `already executing on another node` when it declines ([JobStoreSupport.cs](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Impl/AdoJobStore/JobStoreSupport.cs#L5052-L5066)).

A held-back firing [doesn't queue](https://www.quartz-scheduler.net/documentation/best-practices.html#held-back-work-misfires-it-does-not-queue): past the misfire threshold, the trigger's misfire instruction decides whether it runs late or is skipped, as [Quartz.NET misfire instructions](https://cronradar.com/blog/quartz-net-misfire-instructions) explains. Hangfire's attribute instead makes the second run wait, then fail if the lock isn't free in time ([Hangfire DisableConcurrentExecution](https://cronradar.com/blog/hangfire-disable-concurrent-execution)). The guarantee rests on the fired-trigger rows, which a wrongful write-off deletes; recovery of such a job is deferred for "roughly two check-in intervals plus the check-in misfire threshold," but [a window remains](https://www.quartz-scheduler.net/documentation/best-practices.html#disallowconcurrentexecution-bounds-one-job-key).

`[PersistJobDataAfterExecution]` writes the job's changed data map to its row when `Execute` returns ([JobStoreSupport.cs](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Impl/AdoJobStore/JobStoreSupport.cs#L5332-L5345)), so the next run sees it on any node. Change `context.JobDetail.JobDataMap`; values set on `MergedJobDataMap` are [not saved](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/IJobExecutionContext.cs#L57-L78). Pair it with `[DisallowConcurrentExecution]`, or two runs race over whose data is stored.

The catch: with the DI integration, every start replaces stored jobs and triggers with the definitions in code (`OverWriteExistingData` defaults to `true`), so any node's restart resets that data ([XMLSchedulingDataProcessor.cs](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Xml/XMLSchedulingDataProcessor.cs#L768-L807)). Keep cursors and counters in your own table, or set `Scheduling.OverWriteExistingData = false` and `IgnoreDuplicates = true`, which also stops code changes to existing triggers from applying.

## Node Clocks Must Agree Within a Second

The docs: "Never cluster separate machines unless their clocks are synchronized by a time-sync service that runs very regularly (clocks within a second of each other)." Each node decides a trigger is due by its own clock ([QuartzSchedulerThread.cs](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Core/QuartzSchedulerThread.cs#L305-L321)), and failure detection compares one node's written check-in time with another node's clock; ["the database's clock plays no part"](https://www.quartz-scheduler.net/documentation/troubleshooting.html#clock-skew-between-nodes). On the defaults, a node "more than about seven seconds ahead of a peer's can write off a healthy peer" ([best practices](https://www.quartz-scheduler.net/documentation/best-practices.html#clocks-in-a-cluster)).

Run NTP or chrony on every host. Containers share their host's wall clock, because Linux time namespaces "do not virtualize the `CLOCK_REALTIME` clock" ([time_namespaces(7)](https://man7.org/linux/man-pages/man7/time_namespaces.7.html)).

## Five Misconfigurations That Break a Cluster

### Nodes With Different Job Sets

Any node may acquire any trigger. A node whose build lacks the job class logs `Error retrieving job, setting trigger state to ERROR.` and stores the trigger as `ERROR` ([JobStoreSupport.cs](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Impl/AdoJobStore/JobStoreSupport.cs#L4457-L4478)), which stops the job on every node. A rolling deploy that adds a job is the usual cause, and an old node restarting mid-rollout also writes its older definitions back.

Ship a new job class to every node before the release that schedules it, then call `scheduler.ResetTriggerFromErrorState(triggerKey)` if one already failed. Startup only adds and replaces, so a job removed from `AddQuartz` stays in the database until you call `DeleteJob`. Keep work off certain nodes with an execution group limited to `0` there, or a separately named scheduler. [Quartz.NET job not firing](https://cronradar.com/blog/quartz-net-job-not-firing) covers other ways a trigger stops.

### Instance ID Collisions

`UseClustering()` leaves the instance ID at `NON_CLUSTERED` in 3.x, and 3.22.7 warns that nodes sharing it each treat "the others' fired triggers as its own, none sees another fail, and a restarted node's recovery replays a live peer's firings" ([StdSchedulerFactory.cs](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Impl/StdSchedulerFactory.cs#L1275-L1303)). A fixed ID in configuration every replica receives does the same. `AUTO` appends a timestamp to the host name ([SimpleInstanceIdGenerator.cs](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/Simpl/SimpleInstanceIdGenerator.cs#L46-L50)), so it differs between nodes and restarts.

### A Node That Stopped Checking In

A crashed node's rows stay until a peer writes it off, so if every node is down, nothing recovers until one starts. Worse, if a node holding `TRIGGER_ACCESS` loses its network without the database noticing, "every node of a cluster stops firing at the same moment, and the log is silent" ([troubleshooting](https://www.quartz-scheduler.net/documentation/troubleshooting.html#a-lock-held-by-a-connection-that-is-gone)). Set `quartz.jobStore.commandTimeout` (3.22.0 and later) to turn the wait into a reported failure, and have the database drop dead sessions, for example with PostgreSQL's `idle_in_transaction_session_timeout`.

### Separate Clusters Sharing Tables

Two applications can share tables under different scheduler names. Two that both keep `QuartzScheduler` and the `QRTZ_` prefix become one cluster: each acquires the other's triggers, can't load their job classes, and sets them to `ERROR`. A staging slot or canary running production's configuration against production's database joins too. Give each application its own name, prefix, or database. [Why a cron job runs twice](https://cronradar.com/blog/cron-job-running-twice) shows how to tell duplicate launchers apart.

### Connection Pool Exhaustion

Size each node's pool to at least `maxConcurrency` plus 3 when clustered: one connection per worker, plus the scheduler thread, misfire handler, and cluster manager ([best practices](https://www.quartz-scheduler.net/documentation/best-practices.html#the-connection-pool-is-the-thread-pool-plus-three)). With 10 threads, `Maximum Pool Size=13` in the Npgsql connection string covers Quartz alone; a job's own `DbContext` on the same connection string draws from that pool too. Every node shares one database's connection budget.

## Monitor Each Job Once, Whichever Node Runs It

A cluster mostly fails silently: a lost lock stalls every node, a trigger sits in `ERROR`, an unrecovered run is gone. An external schedule-based monitor catches these because it alerts on reports that don't arrive. As in [distributed cron](https://cronradar.com/blog/distributed-cron), use one monitor per job, reported by whichever node ran it.

CronRadar.Quartz does this for Quartz.NET 3.x, version 3.4 and later. **Package status checked October 25, 2026:** NuGet installs CronRadar.Quartz 0.1.0, which provides `MonitorAll`. It walks the scheduler's jobs, declares each under a key derived from its `JobKey` or `[CronRadar("key")]`, and adds a job listener that reports start, completion, and failure; it sends a cron trigger's six-field expression unchanged, which CronRadar rejects, so in 0.1.0 only jobs on repeating simple triggers are monitored ([integration guide](https://docs.cronradar.com/integrations/quartz)). Every node reads the same job store, so every node finds the same jobs and keys.

Call it on every node. "Listeners are **NOT** stored in the JobStore" ([listeners](https://www.quartz-scheduler.net/documentation/quartz-3.x/tutorial/trigger-and-job-listeners.html)), so a node that skips it runs its share of firings unreported, and those runs look missed. With the hosted service, use the guide's bootstrap:

```bash
dotnet add package CronRadar.Quartz
```

```csharp
using CronRadar.Quartz;
using Quartz;

public class CronRadarBootstrap : IHostedService
{
    private readonly ISchedulerFactory _factory;
    public CronRadarBootstrap(ISchedulerFactory factory) => _factory = factory;

    public async Task StartAsync(CancellationToken ct)
    {
        var scheduler = await _factory.GetScheduler(ct);
        await scheduler.MonitorAll();
    }

    public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}
```

Register it with `builder.Services.AddHostedService<CronRadarBootstrap>();` before `AddQuartzHostedService`, so `MonitorAll()` runs before the scheduler starts, and load `CRONRADAR_API_KEY` from a secret store, using one CronRadar application per environment. `[SkipMonitor]` on a job class opts it out. The extension catches its own network errors and never changes how a job runs. In a cluster:

- **The cluster stops firing.** No report arrives. The monitor shows `warning` once the expected time passes and goes `critical` once the run is past the grace period and at least two minutes late. Cover run time plus takeover delay by setting `CronRadar.CronRadar.DefaultGracePeriodSeconds` on every node before `MonitorAll()`: 0.1.0 sends that value, 60 by default, on every reconciliation, replacing a grace period edited in the monitor's settings.
- **A job throws on any node.** The `fail` report alerts immediately.
- **A node dies mid-run.** Its `start` has no finish. Every check-in moves the expected time to the next occurrence and the next run's `start` resets the window, so this is flagged as stuck only if the job then goes silent. With `RequestRecovery()`, the recovery run reports its own result.
- **The job runs on every node.** CronRadar has no duplicate-run alert; the misconfiguration shows up only as extra check-ins on the job's monitor.

CronRadar reads schedules in UTC. The trigger above is UTC, but 0.1.0 sends `0 0 2 * * ?` unchanged and CronRadar rejects it, so until the pending 0.1.1 this job needs `[SkipMonitor]` and a base-SDK report with `0 2 * * *`; for other zones, compare CronRadar's next expected run with the trigger's next fire time. A cron trigger firing more than once a minute has no five-field form; a repeating simple trigger under a minute is monitored as `* * * * *` ([schedules](https://docs.cronradar.com/schedules)). [Quartz.NET monitoring](https://cronradar.com/blog/quartz-net-monitoring) covers listeners and health checks.

The guide doesn't cover Quartz.NET 4.x, where `Execute` returns `ValueTask`. There, wrap the work with the base `CronRadar` package (1.0.13) inside the job, so only the node that runs it reports:

```csharp
using Quartz;

[DisallowConcurrentExecution]
public sealed class NightlyInvoicesJob(InvoiceRun invoices) : IJob
{
    public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
    {
        var billingDate = DateOnly.FromDateTime(
            (context.ScheduledFireTimeUtc ?? context.FireTimeUtc).UtcDateTime);

        // The class shares its namespace's name, so qualify it fully.
        await CronRadar.CronRadar.WrapAsync(
            "billing-nightly-invoices",
            () => invoices.CreateOnceAsync(billingDate, cancellationToken),
            schedule: "0 2 * * *");
    }
}
```

## Frequently Asked Questions

### Does Quartz.NET clustering need a database?

Yes. Clustering works only with the ADO.NET job store, `JobStoreTX` or `JobStoreCMT`, with every node on the same tables. `RAMJobStore` can't be clustered, because each process keeps its own schedule in memory.

### Why does my Quartz.NET job run on every instance?

Usually each instance has its own `RAMJobStore`. With a shared database, check that every node has the same `instanceName` and `clustered = true`, and that every job and trigger has an explicit identity, since unnamed ones gain a copy at each start.

### Does Quartz.NET balance load across cluster nodes?

Not evenly. The first node to lock a due trigger fires it, which is near-random when many triggers are due and favors one node when few are. Execution groups (3.18) and preferred nodes (3.19) give some control.

### What happens if a Quartz.NET node dies mid-job?

About 15 seconds after its last check-in on the defaults, another node writes it off. Jobs marked `RequestRecovery()` run again on a surviving node with `context.Recovering` set to `true`; other interrupted runs are lost until their next fire time.

### How do I monitor jobs in a Quartz.NET cluster?

Use one monitor per job, not per node, reported from whichever node ran it. With CronRadar.Quartz, call `MonitorAll()` on every node, because Quartz.NET doesn't store listeners in the job store. A schedule-based monitor then catches a cluster that silently stops firing, which no node can report.
