NewYour coding agent can read the release notes before it upgrades.Set up the MCP server →
NuGet · #327 most downloaded on NuGet
Quartz.NET job scheduler - cron, calendar and interval triggers, clustering over a persistent job store, and Microsoft.Extensions hosting, logging, options and dependency injection throughout. The core package: everything else builds on this one.
Last release today
07 Oct 2026
Release timing varies
gaps range from 8 days to 4 months
Some releases are documented
notes for 25 of the last 60 stable releases
9 versions withdrawn
withdrawn after publishing
127 years old
94 releases · first in 1900
New QuartzSchedulerOptions.RecordExceptionSpanEvents , since OpenTelemetry deprecated span events in 2026. Unset, Quartz follows OTEL_SEMCONV_EXCEPTIO…
Quartz.NET 4.4 is history you can run a business on. Each run says what it achieved: succeeded, failed,
cancelled or skipped, with a summary and metrics. The history keeps that per job, sweeps it by result and charts
it over time, and a health check degrades when a required job stops succeeding. Recovery respects rolling
deploys, and triggers already due fire in the transaction that acquires them: at 20 triggers a second on
PostgreSQL, 95–99 % of firings land within ±50 ms, up from 54 % on 4.3.0. Quartz.Weasel now manages the job store's schema on six databases, adding MySQL, Oracle and Firebird.
dotnet add package Quartz --version 4.4.0context.Result, a status per job, retentionRequireSuccessWithin(jobKey, TimeSpan.FromHours(26)) degrades when a nightly jobRunAtStartup, scheduling a triggerQuartz.Weasel.MySQL, Quartz.Weasel.Oracle and Quartz.Weasel.Firebird joincontext.Result = JobRunReport.Skipped("…").With("released", 3).
JobRunResult {Succeeded, Failed, Cancelled, Skipped}.MisfireReason.Vetoed). Manual runs (TriggerJob) are marked.JobRunStatus: last run, last success and failure, consecutive failures, counts.RetentionByResult, MisfireRetention) and MaxEntriesPerJob.database/migrations/database/migrations/…. (#3972)JobExecuted event carry it, not the wrapper's "Job threw an unhandled exception". Logo.RequireSuccessWithin(new JobKey("nightly-report"), TimeSpan.FromHours(26)), or a RequiredJobs listIExecutionHistoryStore.QueryExecutionStatistics (a default member), with an HTTP route and a clientPERCENTILE_CONT on PostgreSQL and Oracle, a ranked read elsewhere.ExecutionHistoryOptions.RecordInput records a run's JobInput, up to MaxInputBytes (defaultTriggerJob(key, data) and says whether it did.JOB_INPUT and JOB_INPUT_TOO_LARGE, in the optional 4.4 migration.quartz.job.execution.duration gains a quartz.job.result tag: succeeded, failed, cancelled orskipped, classified by the same rule as the history row. error.type is unchanged: it is set on a failedQuartz.Job.Execute span gets quartz.job.result too. A failed store span now carries error.type.QuartzSchedulerOptions.RecordExceptionSpanEvents, since OpenTelemetry deprecated span events in 2026.OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN, so logs turns the events off. The default is[RetryPolicy(3, "00:00:30")] (Fixed), [RetryPolicy(5, "00:00:10", 2.0)] (Exponential), Explicit delays,[RetryPolicy(0)] (never), plus q.UseDefaultRetryPolicy(…).RetryPolicy.None refuses at any level.SendMailJob and NativeJob carry [RetryPolicy(0)].q.RunAtStartup(jobKey).
@reboot is still rejected, and its message names RunAtStartup.ScheduleJobOptions/AddTriggerOptions gain Paused, PauseReason and PauseRequestedBy. The shippedIJobStore.SupportsStoringPaused default member. Without it, thePauseTriggersWith / PauseJobsWith on IScheduler, IJobStore and IQuartzApiClient./keys/pause routes take the reason. A reasonless 4.3 client still gets the exact reasonlessIJobListener.JobProgressChanged(context, progress), a default member.JobWasExecuted. It is called off the job's thread and is in-processThe database store makes one transaction per round instead of two, and sends each round's claims and fire
writes as one batch each.
The in-memory store takes one lock per round instead of two.
PostgreSQL, against the released 4.3.0 (three alternating sittings; the full table is under Measured):
| Workload | 4.3.0 | 4.4.0 |
|---|---|---|
| 20 triggers a second, firings within ±50 ms | 54 % | 95.5–99.2 % |
| 100 triggers a second, firings within ±250 ms | 68–70 % | 99.5–100 % |
| One-off throughput | 258–272 a second | 420–429 a second |
| Two-node drain | 293–298 a second | 456–491 a second |
A custom IJobStore keeps the old two-step path through the new AcquireNextTriggersAndFireDue default
member.
A custom StdAdoDelegate keeps every override it has. The new IDriverDelegate.SupportsFireOnAcquire
answers true only for the shipped delegates, and a subclass gets the one-transaction round through the
per-trigger members it may override.
AdoJobStoreOptions.RecoverFiringsCancelledByShutdown (keyquartz.jobStore.recoverFiringsCancelledByShutdown).
RequestsRecovery job cancelled by a graceful shutdown gets a recovery trigger instead of completing asInterrupt or [JobTimeout] stays a cancellation. The in-memory store is not affected.[DisallowConcurrentExecution] job-mates.
AdoJobStoreOptions.MaxConsecutiveFireFailures (default 5, 0 = never), flat keyquartz.jobStore.maxConsecutiveFireFailures.ResetTriggerFromErrorState.InMemoryJobStoreOptions.MaxConsecutiveFireFailuresBLOCKED.AddCalendar(…, updateTriggers: true) works out every trigger's new fire time on a clone first, and fails asJobPersistenceException.UseWeaselForMySql() andUseWeaselForOracle().
GET_LOCK user lock,CURRENT_SCHEMA. There is no lock, because DBMS_LOCK needs a grant. AProvisionSchema() does.UseWeaselForFirebird(), for Firebird 3, 4 and 5.
MaxIdentifierLength allows up to 63 on Firebird 4 and[9.39.0, 10.0.0).
IDX_QRTZ_T_NFT_ST on every apply. JasperFx/weasel#660SchedulerException with the original inside.ProvisionSchema() leaves it out too, as the fresh-install script always has.Thanks to the JasperFx maintainers for reviewing and merging Weasel.Firebird (JasperFx/weasel#666).
Weasel schema management
| Script | Required | Adds |
|---|---|---|
4.4/add_execution_outcome_<dialect>.sql |
only with execution history on | RESULT, SUMMARY, METRICS, MANUAL, FIRE_INSTANCE_ID, JOB_INPUT, JOB_INPUT_TOO_LARGE on QRTZ_EXECUTION_HISTORY; IDX_QRTZ_EH_JOB_TIME; table QRTZ_JOB_STATUS (#3972, closes #3958; #4021) |
The new columns are nullable, and the script is safe to run beside 4.3 nodes. The Weasel models carry it.
/history/job-status, with a version check so a client never sends a 4.4 filter to anreasons names it, so a 4.3 reader never meets a name it can'tnull, or leaves only that item out of a listing. EachMapStaticAssets), and ExamplesSmoke assertsblazor.web.js.| Now | In 4.3 | PR |
|---|---|---|
A cancelled run is recorded as Cancelled (Succeeded false) |
recorded as a success | #3971 |
context.Outcome reads Vetoed inside JobExecutionVetoed |
read Succeeded |
#3971 |
Vetoed firings appear in the misfire feed (MisfireReason.Vetoed) |
not recorded | #3971 |
TriggerJob firings carry QRTZ_MANUAL_TRIGGER in the trigger's JobDataMap |
no marker | #3971 |
History, job status and the JobExecuted event carry the job's own exception message |
"Job threw an unhandled exception"; log templates unchanged | #3995 |
A trigger that fails to fire 5 times in a row is stored ERROR, in both stores (MaxConsecutiveFireFailures, 0 = off) |
persistent: released and acquired again; in-memory: batch stranded | #3973, #3981 |
| A calendar's misfire failures count at most once per misfire threshold, after the store's commit | not counted | #4025 |
| The resume calls log a calendar's exception | thrown | #4025 |
WithRetryPolicy(null) means "inherit"; manual and recovery runs inherit too |
cleared the policy | #4028 |
SendMailJob and NativeJob carry [RetryPolicy(0)]: the scheduler default never retries them; a trigger's own policy still applies |
no attribute | #4028 |
A Weasel SQLite apply failure is a SchedulerException with the original inside |
Weasel's InvalidOperationException |
#4007 |
| Quartz.Weasel.SQLite rebuilds a table carrying your own objects | refused | #3978 |
The HTTP misfire listing leaves Vetoed out unless reasons names it |
— | #3977 |
| Dashboard: Complete is Succeeded; Job Detail's history link opens that one job | Complete | #3977 |
| Dashboard stat cards count the window | counted the page | #4027 |
A firing committed on acquisition runs even if Shutdown() was called mid-round |
— | #4011 |
A trigger fired on acquisition has no TriggersFired span; the acquisition span carries the count |
a TriggersFired span |
#4011 |
OTEL_SEMCONV_EXCEPTION_SIGNAL_OPT_IN=logs turns exception span events off (RecordExceptionSpanEvents overrides) |
ignored | #4020 |
| A clustered node re-polls after 100 ms, up to 5 s, for a trigger a serial job on another node holds | waited a full idle wait | #4032 |
| Replacing a trigger clears an earlier pause's reason | reason left on the row | #4023 |
| A recovery trigger recovered again keeps the original firing's markers | overwritten | #4030 |
QRTZ_JOB_STATUS, so theVetoed in the HTTP misfire listing. A 4.2 misfire row with no reason reads as Missed.An application on 4.3 compiles on 4.4 unchanged. On a persistent store with the execution history on, run
4.4/add_execution_outcome_<dialect>.sql before the first 4.4 node starts. Every change, and the full list of
mixed-version notes, is in the migration guide's
Upgrading from 4.3 to 4.4.
Additions only: +278 lines, none removed, and every member added to an existing interface has a default implementation.
| Assembly | Lines added |
|---|---|
| Quartz | 211 |
| Quartz.Dashboard | 20 |
| Quartz.Weasel.MySQL (new) | 16 |
| Quartz.Weasel.Firebird (new) | 14 |
| Quartz.Weasel.Oracle (new) | 13 |
| Quartz.HttpClient | 2 |
| Quartz.Jobs | 2 |
The whole delta, member by member: git diff v4.3.0 v4.4.0 -- src/Quartz.Tests.Unit/Verify src/Quartz.Tests.AspNetCore/Verify.
PostgreSQL 15.1 in Docker (fsync and synchronous commit on), pool 10, three alternating sittings of 4.3.0 and 4.4.0 on one quiet machine.
| Measure | 4.3.0 | 4.4.0 |
|---|---|---|
| One-off throughput, defaults | 3.67–3.88 ms a firing (258–272/s) | 2.33–2.38 ms a firing (420–429/s) |
| Allocated per one-off firing | 72.1 KB | 60.8 KB |
| Statements per firing, history off | 16.5 | 13.8 |
| 20 triggers/s, within ±50 ms (max deviation) | 53.8–54.3 % (78–83 ms) | 95.5–99.2 % (51.5–54.2 ms) |
| 100 triggers/s, within ±250 ms | 68.2–70.3 % | 99.5–100 % |
| Clustered drain, 2 nodes | 293–298/s | 456–491/s |
| Clustered drain, 4 nodes | 287–301/s | 379–504/s |
| In-memory S1 / S4 | — | not worse; fire-ahead punctuality max 1 s → 15 ms |
[9.39.0, 10.0.0), which needs JasperFx 2.76.0 or later. (#4007)Quartz.Weasel.MySQL, Quartz.Weasel.Oracle and Quartz.Weasel.Firebird. The packagedotnet test --project ….
Microsoft.NET.Test.Sdk (RIDER-131530), and a test guards global.json's runner--report-github.One column per quarter.
Shipped dependency floors are unchanged from 4.2.0 — each is the lowest version with no known vulnerability. Test and example projects no longer pin t…
Quartz.NET 4.3 is an additive minor. Every public change is a new type, a new member on a type we own, or a default
interface member, and its schema change is one generated folder, database/migrations/4.3/, that only adds
nullable columns. A released 4.2 node and a 4.3 node share one PostgreSQL database in CI to prove a mixed cluster
runs safely. A 4.2 application upgrades by changing the version and, on a persistent store, running the 4.3 scripts
before the first 4.3 node starts. The headlines: a scheduler on a database fires one-off jobs 2.8× faster than 4.2
at its shipped defaults; a job can be a lambda; and a schedule can be steered while it runs, with an overlap policy,
a pause that says why, backfill, progress and captured logs, and a dashboard that edits. Four new packages put the job store's tables under Weasel, for applications on Marten or Wolverine.
dotnet add package Quartz --version 4.3.0A job that takes nothing from the container — no constructor parameters, not registered, no ConfigureScope —
is built without a DI scope: 1.84 → 1.68 KB a firing on RAMJobStore, a little faster too. Every other job keeps
its scope exactly as before. (#3866)
RAMJobStore computes a cron trigger's next fire time once per firing, not twice, inside its lock. (#3866)
A scheduler on a database batches what is already due, out of the box. MaxBatchSize now defaults to
automatic: the thread pool's size on a persistent store, clustered or not, and 1 in memory.
Nothing fires early (the fire-ahead window is still zero). On PostgreSQL a backlog of one-off jobs goes from
about 80–91 to 125 firings a second, and from 6.0 to 2.8 commits a firing. MaxBatchSize = 1 restores 4.2.
On a cluster, the flip was gated on a 2- and 4-node drain against a durable PostgreSQL: 1.7–2.1× faster on
two nodes and 1.6–2.0× on four, at a lock-wait p99 of about 60 ms. One of four sittings read 0.78× on four nodes
while every lock wait on that shared VM was slower. (#3862, #3900)
Scheduling a job costs less. ScheduleJob with a simple trigger allocates 2.76 KB, down from 3.83 KB,
and a cron one 3.3 KB, down from 4.6 KB. Scheduling a trigger later than the one the scheduler is already
waiting for no longer wakes it, and listener lists are built when they change, not on every call. (#3865)
A firing that may run beside itself completes without the store's lock. Completions of
concurrent-allowed jobs no longer queue behind each other on TRIGGER_ACCESS. With the batching default above,
one-off jobs on PostgreSQL go from 4.2's 91 to 237 firings a second on the same database: 2.6×. Repeating
triggers fire 1.5–1.9× faster. A 2- and 4-node cluster drains 1.5–1.8× faster at a fifth of the lock wait. A
[DisallowConcurrentExecution] job, a retry, a trigger with continuations, a buffered overlap policy and a
non-durable job's last trigger keep the lock. The misfire pass also settles a continuation whose parent no
longer exists. (#3863)
Measured on the release candidate against 4.2.0, alternating sittings on one box and one durable PostgreSQL:
| 4.2.0 | 4.3.0 | |
|---|---|---|
| One-off jobs on PostgreSQL, shipped defaults | 90–91 a second | 251–255 a second (2.8×) |
| Repeating triggers on PostgreSQL at 20 a second, within ±50 ms | 22 % | 52 % (all within ±250 ms; worst 84–96 ms, was 205 ms) |
| In-memory throughput, bytes per execution | 2.81 KB | 2.07 KB, and not slower |
| Scheduling one simple trigger | 3.4–3.6 KB | 2.6–2.7 KB |
In memory every firing still lands within ±50 ms.
q.ScheduleJob("cleanup", static async (IRepo repo, ILogger<Cleanup> log, CancellationToken ct) => …, t => t.WithCronSchedule("0 0 * * * ?"))
— the parameters are what the code needs: the firing (IJobExecutionContext), its token, its scope
(IServiceProvider), or any service from that scope. AddJob(name, handler) adds a durable one. It is stored as
Quartz.Impl.DelegateJob keyed by the job key, so it persists and clusters like any job; every node registers the
handler. (#3867)
The compiler binds a lambda handler: Quartz's source generator intercepts the call, so a firing resolves its
parameters with generated code, in 14 ns and allocating nothing, instead of by reflection (51.6 ns, 72 B). Any
other handler is bound by reflection once at registration, native AOT included. Delegate jobs (#3882)
context.ReportProgress(40, "copied 400 of 1,000") shows as a progress bar on the dashboard's Currently Executing
page, cluster-wide (written at most once a second, only on change). q.UseExecutionLogCapture() keeps the log lines
a firing writes — bounded by lines and bytes — with its history entry, and a new execution page shows one run with
its outcome, timings, exception and log. Hangfire.Console's job, in the box. Progress and execution logs (#3874)
t.WithOverlapPolicy(OverlapPolicy.Skip) drops an occurrence that comes due while the trigger's previous firing
runs (recorded in misfire history with reason Overlap); BufferOne holds one and fires it when the previous
ends; CancelPrevious interrupts the running firing and starts the new one (on another node it holds instead);
AllowAll overlaps freely. Default is 4.2's behaviour and costs nothing. [DisallowConcurrentExecution] still
wins. Temporal's and Kubernetes CronJob's knob, per trigger, in every store. New
ITriggerListener.TriggerSkipped. Roll every node to 4.3 before relying on it: a 4.2 node ignores the
policy. Overlap policy (#3875)
scheduler.PauseTriggerWith(key, new PauseDetails { Reason = "vendor API down", RequestedBy = "ops" }) (and
PauseJobWith, PauseTriggerGroupsWith, PauseJobGroupsWith, PauseAllWith) records why, who and when;
GetTriggerPause(key) reads it back, and the dashboard shows it beside the pause. The HTTP pause routes take an
optional { reason, requestedBy } body, with the requester defaulting to the signed-in user.
q.PauseTriggerWhenRetriesExhausted() pauses a trigger whose retry policy gave up, with the failure as the
reason. A pause without a reason is exactly 4.2's pause. Pausing with a reason (#3879)
Edit a trigger in place: description, priority, calendar, misfire instruction, execution group, retry policy,
preferred node, overlap policy and its job data, with each save written to the action log (now with a User column).
Filter the Triggers list by group, name, job, calendar, state and "next fire before", and Jobs by group and name.
Every filter lives in the URL. Select rows to pause, resume or unschedule them together. It works the same over an
HTTP target and a store-attached window. (#3878)
limits.ForGroupsWithPrefix("tenant:", 2, ExecutionLimitScope.Cluster) gives every groupWithExecutionGroup("tenant:{TenantId}") and[ExecutionGroup("tenant:{TenantId}")] fill the group from the trigger's job data; a one-off saysExecutionGroup = $"tenant:{input.TenantId}". Concurrency keys, which Oban, Sidekiq, JobRunr and BullMQOneOffJobOptions.OnConflict is Throw (as before), Replace, KeepKeepEarlier (whichever fires first stays). NewIScheduler.ScheduleTrigger(trigger, onConflict) / IJobStore.StoreTrigger(trigger, onConflict) reportonConflict. (#3877)scheduler.Backfill(triggerKey, from, to) schedules one run of the trigger's job for every slot its schedule had in
[from, to): calendar applied, the job data, priority, retry policy and execution group carried, the slot readable
in the job through context.GetBackfillSlot(). Re-running the range skips slots still pending, MaxSlots (1,000)
and Spacing bound it, and a future range is refused. Misfire handling is for when the scheduler was down; backfill
is for a range you choose. Over the HTTP API it is one audited request (POST …/triggers/{group}/{name}/backfill),
and the dashboard's trigger page has a Backfill dialog. Backfill (#3880)
Quartz.Weasel.PostgreSQL, Quartz.Weasel.SqlServerQuartz.Weasel.SQLite put the ADO.NET job store's schema under Weasel. db-apply,db-assert, db-patch, resources setup and AutoCreate at startup then create and migrate Quartz's tables beside theProvisionSchema() and hand-run scripts. One call on the store:store.UseWeaselForPostgres(), UseWeaselForSqlServer() or UseWeaselForSqlite(). (#3941, #3948)database/tables/,ProvisionSchema() or the migrations reads as unchanged, and a 3.x or 4.2 schema is brought up to 4.3 in place. TablesAdvancing a FakeTimeProvider now wakes the scheduler: clock.Advance(TimeSpan.FromHours(1)) fires what was due
in that hour, with no real waiting. JobExecutionContextBuilder.For(job).WithTrigger(trigger).FiredAt(when).Build()
builds a context for a job's unit test in one line. Testing (#3869)
t.WithCronSchedule(cron => cron.AtTime(new TimeOnly(3, 0)).OnWeekdays()) configures the expression inline,CronExpressionBuilder.Every(TimeSpan.FromMinutes(10)) is a clock-anchored interval0 0/10 * ? * *). (#3868)q.AddJobLogScope() puts the job, trigger and fire instance on every log line a firing writes. Opt-in:[CronTrigger("0 0 2 * * ?", ConfigurationKey = "Jobs:Cleanup:Cron")] reads the schedule from configuration,QZ1005;QZ1004 is now Info, because AddDeclaredJobsFrom<Assembly>() is always emitted beside AddDeclaredJobs(). (#3871)[SimpleTrigger("00:10:00")] declares an interval job the way [CronTrigger] declares a cron one: RepeatCountConfigurationKey. New QZ0005TimeSpan at compile time. (#3924)database/migrations/4.3/ is the second schema move since 4.0, and like 4.2's it only adds nullable columns, so a
4.2 node keeps running beside a 4.3 node while you roll:
| Script | Required | Adds |
|---|---|---|
add_fire_progress_<dialect>.sql |
yes | QRTZ_FIRED_TRIGGERS.PROGRESS, PROGRESS_MESSAGE (#3874) |
add_execution_log_<dialect>.sql |
only with execution history on | QRTZ_EXECUTION_HISTORY.EXECUTION_LOG (#3874) |
add_overlap_policy_<dialect>.sql |
yes | QRTZ_TRIGGERS.OVERLAP_POLICY (#3875) |
add_misfire_reason_<dialect>.sql |
only with execution history on | QRTZ_MISFIRE_HISTORY.REASON (#3875) |
add_pause_reason_<dialect>.sql |
yes | PAUSE_REASON, PAUSED_BY, PAUSED_AT on QRTZ_TRIGGERS, QRTZ_PAUSED_TRIGGER_GRPS, QRTZ_PAUSED_JOB_GRPS (#3879) |
Every table script carries the new columns, the memory-optimized and pre-2016 SQL Server scripts included. Each
script is guarded, so running it twice is safe; SQLite's ADD COLUMN is the exception, as before.
A released 4.2.2 node and a 4.3 node share one PostgreSQL database upgraded from the 4.2.0 schema with these scripts,
in every PostgreSQL CI run: 2,000 one-off jobs run exactly once between them, a [DisallowConcurrentExecution] job
never overlaps, continuations settle whichever node completes the parent, and the 4.3 columns keep their values
when the 4.2 node fires, updates or resumes the rows around them. A 4.2 node ignores what the new columns mean
(an overlap policy, a pause reason, progress), so roll every node before relying on those. (#3925)
QuartzSchedulerOptions.MaxBatchSize defaults to 0, meaning automatic (see above); an explicit value wins asquartz.scheduler.batchTriggerAcquisitionMaxCount still maps to it. (#3862)Additions only: 271 lines across Quartz (+238), Quartz.Dashboard (+23) and Quartz.HttpClient (+10), none
removed. Every one of the 45 members added to an existing interface (IScheduler, IJobStore, ITrigger,
IJobExecutionContext, ITriggerConfigurator<T>, IQuartzApiClient and others) has a default body, so an
implementation or forwarder written against 4.2 compiles and runs unchanged.
| New type | For |
|---|---|
OverlapPolicy, MisfireReason |
overlap policy per trigger; why a misfire-history row was written |
PauseDetails, PauseInfo |
pause with a reason |
FireInstanceProgress, ExecutionLogCaptureOptions |
progress and captured logs |
TriggerConflict, ScheduleOutcome, ScheduleTriggerResult |
idempotent enqueue |
ExecutionGroupAttribute |
per-tenant execution groups on a job type |
SimpleTriggerAttribute |
interval jobs declared with an attribute |
SchedulerBackfillExtensions, BackfillOptions, BackfillResult, JobExecutionContextBackfillExtensions |
backfill |
JobExecutionContextBuilder |
a job's unit test |
Four new assemblies: Quartz.Weasel (no public API), Quartz.Weasel.PostgreSQL (PostgresWeaselOptions,
UseWeaselForPostgres, QuartzPostgresFeatureSchema), Quartz.Weasel.SQLite (SqliteWeaselOptions,
UseWeaselForSqlite) and Quartz.Weasel.SqlServer (SqlServerWeaselOptions, UseWeaselForSqlServer).
The full list, member by member, is the migration guide's Upgrading from 4.2 to 4.3.
TRIGGER_ACCESS, its statements committed one by one, and theStandby() that lands while the scheduler is between rounds is no longer slept through, so no trigger firesStandby() has returned. Two schedule calls in quick succession no longer hide the earlier, more urgentIdleWaitTime late or misfire, and could fire during standby afterStandby() + ScheduleJob(). Also in 4.2.2 and 3.22.1. (#3901)FakeTimeProvider nobody advanced a shutdown couldnvarchar(max) on SQL Server rather than truncated to theDbMetadata.DbLargeTextTypeName /ConfigureLargeTextParameter, and a UseOracle overload for the factory path. (#3874)RETRY_ATTEMPT/RETRY_SCHEDULED historyALTER now on[DisallowConcurrentExecution] job running on another node. WhenMaxBatchSize = 1), that[DisallowConcurrentExecution] job's otherBLOCKED for good. On PostgreSQL the error aborted the transaction, so the commit silently rolled backTriggerAcquireResult.ExecutionGroupAtLimit) instead of dropping it, so acquisition reads past it as it doesAddJobTimeout's events (1090 timed out, 1091, 1092) are logged through the container's logging. They used to goLogProvider, so under AddQuartz without SetLogProvider an overrun logged nothing. (#3916)AddJob<T>/ScheduleJob<T> type, so with UseJobFactory<MyFactory>() building jobs from anotherValidateOnBuild failed Build() on dependencies only that factory supplies. The default factory, andHttpScheduler escapes every value it puts in a path or a matcher query, so a name with ?, #, %, & or a/, or one that is . or .., cannot surviveArgumentException naming it, instead of reaching another route. A decorator overHttpScheduler backfills in one request too. (#3917)HttpScheduler sends every call through one route table shared with the server, so a client and the HTTP APIDisallowConcurrentExecution() by its builder, with no attribute on its type, took two slotsTriggerAcquireResult.ConcurrentExecutionDisallowed), and aMassTransit.Quartz needs Quartz.NET 3.x: its range has no upper bound, so NuGet resolves 4.xCS0121) or to start. Pin Quartz to [3.22.0, 4.0). (#3872)Weasel.Core, Weasel.Postgresql, Weasel.SqlServer and Weasel.Sqlite[9.35.1, 10.0.0), and through them on JasperFx 2.74 and the database driver. No existing package gains aVerify 32.x: 33 adds a build-time licence check. (#3885)One fix, additive: no public API change and no schema change. It affects a persistent store when a database error hits one trigger of a fire batch.
One fix, additive: no public API change and no schema change. It affects a persistent store when a database error hits one trigger of a fire batch.
[DisallowConcurrentExecution] job's other triggers BLOCKED for good. On PostgreSQL the error aborted the transaction, so the commit silently rolled back triggers already reported fired: their jobs ran while the store still held them as reserved, and could run again after recovery. The failing attempt now rolls back whole, and the batch fires again without that trigger, which is reported failed. Nothing changes when nothing fails. New Warning event 3049 names the trigger. (#3931, #3942)The same fix ships for 3.x in 3.22.3.
Full Changelog: v4.2.3...v4.2.4
Two fixes, both additive: no public API change and no schema change. The first affects a clustered persistent store that acquires one trigger at a tim
Two fixes, both additive: no public API change and no schema change. The first affects a clustered persistent store that acquires one trigger at a time, which is 4.2's default. The second affects the cron fast path in a process that has asked about other decades.
[DisallowConcurrentExecution] job running on another node. When two nodes reserved triggers of the same such job, the node that lost released its trigger back to WAITING, though the job was still running. With one trigger acquired at a time, that trigger then sat at the head of the node's queue. It was skipped on every pass, it hid the triggers due behind it, and the node slept its whole IdleWaitTime (30 s by default). Two 4.2.2 nodes fired a once-a-second trigger 17 s late on average. A released trigger now stays BLOCKED while its job runs, and acquisition reads past a trigger it has to skip. Nothing was lost or doubled. Found by 4.3's mixed-version cluster test. (#3926, #3932)The acquisition fix ships for 3.x in 3.22.2. 3.x has no cron fast path.
Full Changelog: v4.2.2...v4.2.3
Three fixes, all additive: no public API change and no schema change. The first two are in the scheduler's loop and affect any scheduler on the real c
Three fixes, all additive: no public API change and no schema change. The first two are in the scheduler's loop and affect any scheduler on the real clock. The third affects a SQL Server store under lock contention.
Standby() has returned. The scheduler's loop checked whether it was paused before draining its wake-up signal, so a Standby() that landed between the two, most likely while the loop was waiting for a free worker on a busy scheduler, was lost. The round then went on to acquire and fire a trigger due within the next IdleWaitTime (30 s by default). The loop now checks again after the drain. (#3907)IdleWaitTime late, or went through misfire handling. The same overwrite meant Standby() followed by ScheduleJob() could fire a trigger the loop was holding. The loop now keeps the earliest pending candidate. (#3907)QRTZ_LOCKS, or a 1205 deadlock victim with the default SelectForUpdateLockHandler. SqlClient ran the retry outside any transaction, so the store acted without holding TRIGGER_ACCESS, its statements committed one by one, and the final commit failed. The handler now gives up, and the store retries the whole operation in a fresh transaction. (#3903, #3907)FakeTimeProvider it used to wait forever. (#3907)The same scheduling and lock-handler fixes ship for 3.x in 3.22.1.
Full Changelog: v4.2.1...v4.2.2
Two fixes, both additive; no public API or schema-script change for anyone on the default SQL Server script or another database.
Two fixes, both additive; no public API or schema-script change for anyone on the default SQL Server script or another database.
A scheduler on a persistent store no longer waits forever to shut down on a clock nobody advances. Shutting down waits up to one second for the misfire and cluster check-in loops, and that second was measured on the scheduler's own TimeProvider. On a FakeTimeProvider that is never advanced — the usual way to test time-dependent code — a shutdown that raced the loop's start never returned. The bound is now measured in wall time. Production schedulers on the real clock were not affected; 3.x is not affected. (#3892, backported in #3894)
Execution history works on schemas created from tables_sqlServerMOT.sql or tables_sqlServer_Below2016.sql. 4.2.0 wrote RETRY_ATTEMPT and RETRY_SCHEDULED on every history row, but only tables_sqlServer.sql created them, so with execution history on every history write failed with Invalid column name 'RETRY_ATTEMPT'. Both scripts now create them. (#3894) An existing schema created from either script adds them with (safe to run twice):
IF COL_LENGTH('dbo.QRTZ_EXECUTION_HISTORY', 'RETRY_ATTEMPT') IS NULL
ALTER TABLE [dbo].[QRTZ_EXECUTION_HISTORY] ADD [RETRY_ATTEMPT] int NOT NULL DEFAULT 0;
IF COL_LENGTH('dbo.QRTZ_EXECUTION_HISTORY', 'RETRY_SCHEDULED') IS NULL
ALTER TABLE [dbo].[QRTZ_EXECUTION_HISTORY] ADD [RETRY_SCHEDULED] bit NOT NULL DEFAULT 0;Schemas from tables_sqlServer.sql, from database/migrations/4.2/add_execution_history_sqlServer.sql, or from SchemaProvisioning.CreateIfMissing already have them.
Full Changelog: v4.2.0...v4.2.1
Quartz.NET 4.2 is an additive minor: every public change is a new type, a new member on a type we own, or a default interface member, and its one sche
Quartz.NET 4.2 is an additive minor: every public change is a new type, a new member on a type we own, or a default interface member, and its one schema change is a generated migration under database/migrations/4.2/ that a mixed 4.1/4.2 cluster runs safely. A 4.0 or 4.1 application upgrades by changing the version and, on a persistent store, running that migration before the first 4.2 node starts. The headline is that a job can be declared on its class — [QuartzJob] and [CronTrigger] — with the cron expression checked by the compiler, and that one trigger can wait, in the store, for another's outcome.
dotnet add package Quartz --version 4.2.0Quartz.nupkg now carries an analyzer under analyzers/dotnet/cs: QZ0001 refuses a cron literal the parser would refuse — at WithCronSchedule, CronScheduleBuilder.Create, the CronExpression constructors and parse members, CronCalendar and CronTriggerImpl, honouring a literal CronFormat.Unix — with the parser's own message; QZ0002 refuses a [JobTimeout("…")] that does not parse or is negative; QZ0003 warns on [PersistJobDataAfterExecution] without [DisallowConcurrentExecution]; QZ0004 (Info) notes an Execute that never observes its cancellation token. The analyzer is netstandard2.0 and links the cron parser's own sources rather than a second grammar, so a literal the compiler accepts is one the scheduler accepts; a parity corpus of 128 expressions pins that. <DisableQuartzAnalyzers>true</DisableQuartzAnalyzers> in the project file turns it off (#3846) — ExcludeAssets="analyzers" does not, on the .NET 10 SDK; the package's dependencies are unchanged. (#3803, #3815)[QuartzJob(Name, Group, Description, Durable, RequestRecovery, Scheduler)] on an IJob and one [CronTrigger("…", Name, Group, TimeZone, MisfireInstruction, Priority, Description, ExecutionGroup)] per schedule are read by a source generator inside Quartz.nupkg, which writes an AddDeclaredJobs() extension for IQuartzBuilder calling the same AddJob<T>/AddTrigger<T> you would have written — no reflection, nothing to root for trimming, and the cron expression checked at compile time by QZ0001. QZ1001–QZ1003 refuse an attribute on a type that is not a concrete IJob, two declarations with one identity, and a [CronTrigger] without [QuartzJob]. Scheduler = "…" binds a declaration to one named scheduler. [SimpleTrigger] and a cron read from configuration are the deliberate follow-ups. (#3804, #3818)TriggerBuilder.StartAfter(parentKey, condition) — or scheduler.ScheduleJob<TJob,TInput>(input, Continuation.After(parentKey), …) for a one-off — stores a trigger in the new state Awaiting; when the parent's firing ends, its completion settles every continuation inside its own transaction, on whichever node ran it: a matching outcome (OnSuccess, OnFailure, OnCancellation, OnVeto, or OnAnyOutcome) releases the trigger to fire now, any other discards it, and a scheduled retry settles nothing. A parent deleted while continuations wait releases the OnAnyOutcome ones and parks the rest in Error. Every store call that completes a firing now carries ExecutionOutcome and the exception through TriggeredJobCompleteContext. JobChainingJobListener keeps the recurring case and gains a condition. A continuation can be declared in quartz_jobs.xml (<continues-after>, <continuation-condition>), in quartz_jobs.json and the Quartz:Schedule section (ContinuesAfter, ContinuationCondition), scheduled over the wire, and seen in the dashboard — an Awaiting filter on Triggers, and "Continues after" and "When" on a trigger's detail. Job Continuations is the page. (#3805, #3806, #3819, #3821)IJobExecutionContext.Outcome and RetryScheduled tell a listener what the job did and whether a retry follows; ITriggerListener.TriggerRetriesExhausted is raised once when a policy runs out (a default interface member, so no listener needs to change); the counter quartz.trigger.retries_exhausted counts it; execution history records the attempt and whether a retry was scheduled, the History page and the history routes filter on "failed after retries", and a final failure on the History page has a Run again button, recorded in the Action Log. RetryPolicy.Exponential takes an optional jitter that spreads the attempts. One correction rides along: IJobExecutionContext.RetryAttempt now reports the attempt the firing is to listeners as well — it used to read the trigger's field after ExecutionComplete had already advanced or cleared it. (#3807, #3829)GetNextValidTimeAfter used to make eight TimeZoneInfo queries per answer; it now walks the expression's own bitmasks on the wall clock and resolves the offset through a per-zone table of safe segments — intervals verified against the zone's own answers, with everything within 48 hours of a transition, and every zone the table cannot verify, left to the existing code. Every DST edge case still runs the code that handled it before, and a differential test compares the two paths to the tick across ten zones, half of its instants near transitions. On the machine the benchmark README names: 0 0/5 * * * ? 421 → 36.5 ns and 100 successive occurrences 43.1 → 3.5 µs, zero allocations, against NCrontab's 25.3 ns and 2.7 µs; the second-level form now beats NCrontab. The one cost is 8 bytes on each CronExpression. (#3801, #3820)src/Quartz.Benchmark.Competitors/ runs the same five workloads against TickerQ 10.4.0 and Hangfire 1.8.25 — firing throughput in memory and on PostgreSQL, schedule-to-execute latency, punctuality of one-second schedules, and the cost of one schedule call — with completion counted inside the executing job on every side, and the benchmark README and the operations page carry the tables, the rows Quartz loses included. On the box the README names, Quartz at its defaults executes 181,000–257,000 in-memory one-offs a second (TickerQ 98,000–115,000, Hangfire 58,000–92,000), reaches Execute 58–70 µs after ScheduleJob (TickerQ 14.7 ms, Hangfire 94–235 µs), and fires 100 % of one-second schedules within 50 ms (TickerQ 73–78 %, Hangfire 0–15 %); a schedule call costs 7.7–10 µs against TickerQ's 1.1–2.4 µs, and on PostgreSQL a one-off firing makes too many round trips — which #3824 addresses. The profiling switches that found where the time goes (--profile-fire, --profile-cron, --profile-schedule, --latency) ship in Quartz.Benchmark, and out-of-process benchmark runs work again. On RAMJobStore a firing now allocates 1.83 KB where 4.1 allocated 2.63 KB and dispatches with two fewer thread hand-offs — an empty job data map costs nothing to clone or build, one task per dispatch instead of three, one ambient slot per firing, the run shell handed to the pool as state (IThreadPool.TryRunWithState, a default interface member), the firing's clock read only when a span is listening — 12–14 % faster on the fire-throughput benchmark. And one durable job with thousands of triggers behind it — the one-off API's own shape — no longer costs the store a linear scan and an array of keys on every completion: churn against a job with 20,000 triggers went from 183 µs and 157 KB to 434 ns and 424 B. (#3802, #3816, #3822, #3823, #3826)UsePersistentStore(s => s.UseExecutionHistory()) — or quartz.jobStore.executionHistory = true — records every execution and misfire into QRTZ_EXECUTION_HISTORY and QRTZ_MISFIRE_HISTORY in the scheduler's own database, so a cluster has one history and every node's dashboard, the HTTP API's history routes and a store-attached window read the same rows. Retention is the store's: a sweep on the scheduler's clock deletes past ExecutionHistoryOptions.Retention and trims to MaxEntriesPerScheduler, idempotently, so several nodes sweeping at once is harmless. Writes go on their own connection, never inside a job's transaction, and a failed write is logged and dropped rather than failing the firing. The tables are optional: a 4.2 node validates them only when the history is on, and names the script when they are missing. (#3771, #3825)AddQuartzDashboard(o => o.AttachStore("prod", store => store.UseSqlServer(cs))) discovers the schedulers a database holds and opens a window on each — a never-started scheduler over the shared store, listed with the new origin Window and the target's name, its status derived from the cluster's check-in rows rather than from itself (no rows reads as unknown, not as stopped), with every scheduling action available and the node-local ones — start, stand-by, shutdown, interrupt — hidden with a sentence saying they need an API or agent target. With the database-backed history store the window's History page shows what the nodes ran. This is the shared-storage reach model a cluster behind a load balancer needs, with zero worker changes and no inbound port; the docs say what a window can and cannot do and that it needs the cluster's serializer configuration. (#3772, #3828)[Queue] → execution groups that bound but do not route, retries held in the store rather than the worker, a dashboard that refuses to start until told who may reach it). The best-practices page no longer says Quartz has no retry policy, the quick start leads with the three-line form, and the index says what nobody knew was in the box. (#3808, #3813, #3814)QuartzHealthCheckOptions.StaleFiringTolerance (off unless set; 3 is the documented starting value) asks the store for a schedulable trigger whose fire time passed more than that many misfire thresholds ago and reports Degraded, or Unhealthy past twice the bar — the silent stall that "running" and "store answers" never caught, with the overdue trigger, when it was due and by how much in the report's data. The worse of the check-in and stalled-firing verdicts now wins, where a late check-in used to hide a stall. Underneath, TriggerQuery.NextFireTimeBefore is a new filter every store and the HTTP API honour. (#3809, #3817)| Added | What it is |
|---|---|
[QuartzJob], [CronTrigger] |
the attributes the source generator reads; AddDeclaredJobs() is generated into your assembly |
Continuation, ContinuationCondition, ExecutionOutcome, TriggeredJobCompleteContext, AwaitingContinuation |
a continuation, the outcomes that release it, what a firing did, and the context a store's completion receives |
ITrigger.Continuation, ITriggerConfigurator<TJob>.StartAfter — default interface members; TriggerBuilder<TJob>.StartAfter; ScheduleJob<TJob, TInput>(input, Continuation after, options); JobChainingJobListener.AddJobChainLink(first, second, condition) and JobExecutionVetoed |
declaring a continuation, in the builder, in the one-off call, or on the recurring listener |
IJobStore.FiringComplete(TriggeredJobCompleteContext) — DIM; IDriverDelegate.SelectAwaitingContinuations, ReleaseContinuation, ResetContinuationFireTime, SelectSchedulerNames — DIMs |
what a store and a dialect implement to settle continuations and to be discovered by a window; a store from outside this repository keeps working unchanged |
TriggerState.Awaiting, StoredTriggerState.Awaiting; TriggerHeader.ContinuesAfter, ContinuationCondition; TriggerQuery.NextFireTimeBefore; the AdoConstants names of the new columns, tables and state |
the state on the wire and in the store, and the two new query filters |
IJobExecutionContext.Outcome, RetryScheduled; ITriggerListener.TriggerRetriesExhausted — DIMs; ExecutionHistoryEntry.RetryAttempt, RetryScheduled; ExecutionHistoryQuery.FailedFinally; RetryPolicy.Exponential(…, jitter) and RetryPolicy.Jitter; the meter quartz.trigger.retries_exhausted |
the retry signal, end to end |
IPersistentStoreBuilder.UseExecutionHistory() — DIM; AdoJobStoreOptions.ExecutionHistory |
history in the database |
SchedulerOrigin.Window; SchedulerRegistration.Target; QuartzDashboardOptions.AttachStore, AttachStoreOptions; SchedulerHeaderDto.Target, IsWindow, DisplayName |
store-attached targets |
QuartzHealthCheckOptions.StaleFiringTolerance |
the stalled-firing reading |
IThreadPool.TryRunWithState — DIM |
the pool dispatch that carries state instead of a closure |
ObjectDoesNotExistException |
what scheduling a continuation of a trigger the store does not hold throws, naming both keys; it round-trips over the HTTP API |
Nothing was removed or reshaped. Baseline diff v4.1.1..v4.2.0: Quartz 111 added member lines, 0 removed, twelve of them default interface members; Quartz.Dashboard 12 added, 0 removed (24 lines re-ordered by the generator); Quartz.AspNetCore and Quartz.HttpClient unchanged. The Quartz package now depends on nothing new and carries analyzers/dotnet/cs/Quartz.Analyzers.dll and buildTransitive/net10.0/Quartz.targets, which reads DisableQuartzAnalyzers.
database/migrations/4.2/add_continuations_<dialect>.sql adds CONTINUES_TRIGGER_NAME, CONTINUES_TRIGGER_GROUP and CONTINUATION_CONDITION to QRTZ_TRIGGERS, nullable. Required from 4.2.0: a 4.2 node refuses to start against a database without them, naming the script. Safe to apply while 4.0 and 4.1 nodes run — they never read the columns — but a 4.1 node cannot settle a continuation, so migrate, roll every node, then start scheduling them. 4.2/add_execution_history_<dialect>.sql adds QRTZ_EXECUTION_HISTORY and QRTZ_MISFIRE_HISTORY, optional: needed only with UseExecutionHistory(); a fresh install and ProvisionSchema() create them regardless.
TriggerState.Awaiting is a new value on the wire; a 4.1 Quartz.HttpClient reading a 4.2 host that has continuations sees a state it does not know — upgrade the client. A store outside this repository that overrides TriggeredJobComplete keeps working (FiringComplete forwards to it) and settles no continuations until it implements the new member.Quartz.nupkg carries an analyzer. A project whose cron or timeout literals were wrong builds no longer — that is the point — and a build with TreatWarningsAsErrors sees QZ0003 as an error; <DisableQuartzAnalyzers>true</DisableQuartzAnalyzers> opts out, .editorconfig re-tunes a severity.ObjectDoesNotExistException instead of waiting for ever. A scheduling file may still name a parent declared later in the same file — triggers are stored parent-first — but a parent in another file, or scheduled by a later call, has to come first.ContinuesAfter reads null), waits behind its job when that job disallows concurrent execution and is running, honours its calendar and end time — one released past its end time is discarded — and a continuation that is discarded takes everything waiting on it with it.Start() on one — from code or over the HTTP API, which also refuses standby and shutdown for it — throws before anything runs, where it used to run the cluster's start-up recovery against a live database. During a mixed 4.1/4.2 window, do not pause, resume or reschedule a continuation from a 4.1 node.InternalsVisibleTo can both declare jobs. The one that can see the other's generated registration gets AddDeclaredJobsFrom<AssemblyName>() for its own jobs and a QZ1004 warning saying so; everywhere else AddDeclaredJobs() is unchanged.BEGIN/COMMIT/DISCARD ALL and the foreign-key cascade; two were Quartz asking the same question twice — the continuation settlement select issued again by the trigger delete, and a fired-row delete after the sweep had already removed the row — and both are gone. The rest is round trips and commit durability, not database work (server execution is half a millisecond of an eleven-millisecond firing), and the benchmark project now carries a one-off workload and a database-side census (--commits) that counts them. What that census also says: MaxBatchSize set to the pool size with the fire-ahead window left at zero fires nothing early and runs one-off firings 47 % faster on one node; it is documented with the TRIGGER_ACCESS lock it takes in a cluster, and the default is unchanged. Npgsql's DISCARD ALL on every connection return is priced on the PostgreSQL page. (#3824, #3827)QuartzSchedulerBuilder applies what ConfigureAllQuartzSchedulers recorded. Since 4.0.0 a scheduler built without an application container was skipped by the pass that carries container-wide configuration to every scheduler, so AddQuartzSchedulerEvents() installed nothing on it and, in 4.2, UseExecutionHistory() registered the database-backed store and never the recorder that writes to it — the history stayed empty and nothing said so. The release gate's consumer run found it; the builder now runs the same pass AddQuartz does, in the same place. (#3841)CONTRIBUTING.md now carries the writing style it follows. (#3856, #3857)QZ0004 missing overrides of a base job's Execute; and QZ0001 now reporting a missing cron expression.Quartz.NET 4.1.1 is a maintenance release with one real bug in it: a scheduler that published traces produced one trace per process rather than one pe
Quartz.NET 4.1.1 is a maintenance release with one real bug in it: a scheduler that published traces produced one trace per process rather than one per firing, and it grew for as long as the process lived. Nothing about how a trigger fires changed, the schema is 4.0's, and the public API gained two overloads and lost nothing.
dotnet add package Quartz --version 4.1.1A firing is a trace of its own again — subscribing to ActivitySource("Quartz") gave a single unbounded trace: every span the scheduler's loop opened became the parent of the next one, and each Quartz.Job.Execute hung off whichever was current when it was dispatched. A day of a quiet staging pod arrived at the backend as two trace ids and a tree several thousand spans deep, with the jobs' own EF Core and HttpClient spans buried under thousands of Quartz.JobStore.AcquireNextTriggers. The documentation has promised the opposite since 4.0 — the firing is its own trace root — and nothing tested it, because the observability suite reads tags off one span at a time and a span's parent is not a tag.
Two independent defects made that trace, either sufficient on its own. The store decorator started its span in the synchronous override and stopped it inside the asynchronous continuation: Activity.Current is an AsyncLocal, so the start wrote onto the caller's execution context and the stop put the parent back onto the continuation's, which is discarded — leaving the caller with the span current for good, once per iteration of a loop that lives as long as the process. And the execute span took whatever activity was ambient as its parent, so merely starting the scheduler inside a request decided every firing's trace for the life of the application.
Quartz.Job.Execute and Quartz.Job.Veto are now created with an empty parent context — so a parent-based sampler is asked about a root rather than about a context that is being discarded — with Activity.Current cleared around the start that re-reads it, and restored when the span stops. The store spans are started and stopped on one execution context. The three background loops — the scheduler thread, the cluster manager and the misfire handler — clear Activity.Current on entry, because their task captured the execution context of whoever called Start() and they outlive that call by the whole life of the process. Store spans still belong to whoever made the call, so scheduler.ScheduleJob(…) inside a request stays in that request's trace. (#3797, #3799)
On the reporter's own repro, which expected 7 traces and 7 roots:
| traces | largest trace | executions | trace roots | |
|---|---|---|---|---|
| 4.1.0 | 2 | 30 spans | 7 | 0 |
| 4.1.1 | 31 | 1 span | 7 | 7 |
Quartz.Job.Veto also gained the ActivityLink back to the call that scheduled the firing, which it never carried; a refused fire is now walked back from exactly as an executed one is.CreateActivity rather than added after it. ActivityListener.Sample runs while the activity is created, so a link added afterwards was one no sampler ever saw — and sampling a consumer span by the trace that produced it is the reason links are given to samplers at all.An execution limit or a job timeout can be read from the container — 4.x dropped the AddQuartz overloads that handed the callback an IServiceProvider, and the replacement the migration guide led with (read IConfiguration at registration time) bypasses Configure, PostConfigure and IValidateOptions. Everything else on IQuartzBuilder either lands in named options or has a Func<IServiceProvider, …> shape; UseExecutionLimits and AddJobTimeout computed their value eagerly and registered an internal type, so neither could be reached from the container at all. Both now have a shape that is handed one when the scheduler is built. Precedence is untouched — both shapes TryAdd, so the first declaration in code wins and code still beats the quartz.executionLimit.* keys. The migration guide now leads with the options pattern rather than with reading a section yourself, and names the caveat that made the old advice misfire: a scheduler's options are configured under its own name, so a bare AddOptions<T>() silently configures nothing of a named scheduler's. 3.x is unaffected — its four (configurator, IServiceProvider) overloads are still there. (#3794, d523c89)
The documentation's dead links are fixed — the cron pages pointed at FreeFormatter.com, which is gone; they now answer with the library itself, and a handful of other rotted links across the 1.x–4.x trees were fixed with them. (#3796)
| Added | What it is |
|---|---|
QuartzBuilderExtensions.AddJobTimeout(IQuartzBuilder, Func<IServiceProvider, TimeSpan?>) |
the default job timeout, computed once the container exists |
QuartzBuilderExtensions.UseExecutionLimits(IQuartzBuilder, Action<IServiceProvider, ExecutionLimitsBuilder>) |
execution-group limits, configured with services in hand |
v4.1.0..v4.1.1 baseline diff: 2 added lines, 0 removed. Every package's dependency list is byte-identical to 4.1.0 — the Microsoft.Extensions.* floors stay at 10.0.0, so an application pinned a servicing patch lower still restores.
One source-level wrinkle the second overload brings: AddJobTimeout(null) written with a literal null is now ambiguous between the two shapes. It means what AddJobTimeout() means; write that. Every other spelling — a TimeSpan, a TimeSpan? variable, the named-argument form, no argument at all — is unchanged, and UseExecutionLimits(null) is unaffected.
From 4.1.0: dotnet add package Quartz --version 4.1.1, and nothing else. There is no schema change, no configuration change, and no code change to make.
If you worked around #3797 by leaving AddSource("Quartz") out and opening a root in an IJobExecutionMiddleware, you can drop the middleware and subscribe normally — and keep the Quartz.JobStore.* spans, which are now short traces of their own rather than noise in yours.
Full changelog: v4.1.0...v4.1.1
Quartz.NET 4.1 is an additive minor: every public change is a new type, a new member on a type we own, or a default interface member; the schema is 4.
Quartz.NET 4.1 is an additive minor: every public change is a new type, a new member on a type we own, or a default interface member; the schema is 4.0's; a 4.0 application upgrades by changing the version. The headline is that a scheduler can now be added to, removed from and restarted in a running container — the one item the 4.0 roadmap deferred as "a 4.1 concern" — and around it the additive follow-ups filed during the 4.0 release candidates, an API reference for 4.x, and an honest Wolverine page now that Wolverine has a cron of its own.
dotnet add package Quartz --version 4.1.0ISchedulerRuntime (it extends ISchedulerRegistry, as the 4.0 guide promised) builds a scheduler from a recipe into a container of its own that resolves the application's services, jobs and options from the application's container, binds it where the HTTP API, the dashboard and the health checks already look, drains it before it is replaced, and refuses the cases that cannot be made safe: a name the container registered, a bound name, a recipe that supplies an instance part, a restart whose drain gave up. ISchedulerFactory.LookupScheduler now builds a registered-but-unstarted scheduler on lookup. A restart builds the next generation first (a configuration error leaves the old one running), drains the old one within SchedulerRestartOptions.DrainTimeout (30 s by default) and only then initialises the new store — because the store's recovery sweep is per scheduler name, not per instance — and a drain that gives up leaves the name shut down and reported with no status until the next Restart finishes the job. A recipe that hands over a job store, thread pool, job factory or instance-id generator as an object is refused before anything is built, since a shut-down instance cannot be re-initialised. (#3338; add/remove #3725, restart #3734)MON/2 parses and means what 2/2 means (a step through the week, not 3.x's fortnight), and MON-FRI/2 is 2-6/2 where 4.0 silently read it as MON-FRI. (#3732, #3733 [85214e4…e29f7c4283]). The century walk of CronCalendar.GetNextIncludedTimeUtc (#3690) shipped in 4.0.1.POST …/triggers/{group}/{name}/update-details edits a trigger in place (description, priority, job data, calendar, misfire instruction with its family, preferred node, execution group, retry policy; a field absent from the body is left alone and a null clears it), and HttpScheduler.UpdateTriggerDetails no longer throws — Context and ListenerManager are the two members a wire cannot carry (#3681, #3735). take says in the OpenAPI document why it is a string: a page size, or all for everything up to MaxPageSize (#3682, #3736). GetStatus(ct) and GetSchedulerInstanceId(ct) are the asynchronous twins of the two IScheduler properties a wire cannot answer without blocking — default interface members that answer the property locally, one round trip on HttpScheduler, and on the container's deferred scheduler they build it on first use where the properties throw (#3684, #3738). QuartzHttpApiOptions.IsJobTypeAllowed and its dashboard twin let an operator name which job types a request may schedule — a predicate over the type name as the request spelled it, never a resolved type; a refused name is a 403 naming the type and a warning in the log, and the default is unchanged (#3685, #3741).quartz_jobs.json, in the Quartz:Schedule section and in the XML file (<preferred-node>; * pins it to whichever node fires it first), and the XML format catches up with <execution-group> and <retry-policy>. The schema stays 2.0: the elements are optional and a 3.x file loads unchanged. The freeze on the XML format is redrawn as a freeze on trigger kinds, not settings. (#3683, #3739)AddQuartzDashboard() beside AddQuartzHttpClient("acme", …) now fronts the scheduler in the other process honestly: it is listed with the new origin Remote, every page drives it, the History page and the misfire tile read the history the target's process keeps, and Live Logs says what a wire does not carry yet. Underneath: the scheduler repository no longer reads a remote scheduler's Status while holding its lock — one unreachable target used to stall every scheduler lookup in the process, the HTTP API's own included, for the client's timeout — the registry asks all schedulers concurrently under one two-second budget and reports Unknown for the ones that did not answer, the pages page instead of asking for everything, and a second AddQuartzHttpClient under the same name is refused rather than silently winning. Execution history is now Quartz's rather than the dashboard's: IExecutionHistoryStore in core with an in-memory default bounded by age and count, a recorder AddQuartzExecutionHistory() installs against every scheduler, three GET …/schedulers/{name}/history/… routes the HTTP API serves, and a read-only reader of them that AddQuartzHttpClient registers per target. QuartzHttpApiOptions.ReadOnly turns the whole mutating surface of the API off in one switch — mutation is a property of the route, so the two bulk fetches stay reads. The design for the rest — live events over the wire, an audit of operator actions, store-attached and dial-out targets, a fleet pane — is recorded on #3387 and filed as 4.2 issues (#3768–#3775). (#3387, #3776)Information line, 9007, naming the caller (HttpContext.User.Identity.Name, or (anonymous)), the operation, the scheduler and the route — until now a POST …/shutdown that worked was recorded nowhere. The dashboard's Action Log entries and their 9100/9101 lines now say where an action landed: the scheduler's origin, the node behind it, and whether the action was node-local (start, stand-by, shutdown and interrupting a firing) — and interrupting a firing from Currently Executing is recorded at all, which it was not. (#3769, #3784)opts.Schedules.ScheduleRecurring, over Cronos), and the page now puts the two side by side in one table, says plainly when Wolverine's own schedule is the right answer, and keeps the recipe for what a Quartz trigger adds — with a seventh example part running Wolverine's schedule beside Quartz's. (#3719, #3737)| Added | What it is |
|---|---|
ISchedulerRuntime : ISchedulerRegistry — Add, Remove, Restart |
schedulers in a running container; resolve either interface and you get the same object |
SchedulerAddOptions, SchedulerRestartOptions (readonly record structs), SchedulerRestartException |
their options and the one refusal a caller can act on |
IScheduler.GetStatus(ct), IScheduler.GetSchedulerInstanceId(ct) — default interface members |
the asynchronous twins of the two properties; a scheduler written outside this repository needs no change |
HttpScheduler.GetStatus / GetSchedulerInstanceId overrides |
one round trip each, no blocked thread |
QuartzHttpApiOptions.IsJobTypeAllowed, QuartzDashboardOptions.IsJobTypeAllowed |
an allow-list over the job-type name a request spells |
SchedulerOrigin.Remote; SchedulerRegistration.SchedulerInstanceId (init) |
a scheduler reached through a proxy, and the node a listing asked it for — asynchronously, under a deadline, never through the blocking property |
QuartzHttpApiOptions.ReadOnly |
every mutating route answers 403 problem details; the two bulk fetches stay reads |
QuartzHttpApiOptions.EventStreamHeartbeatInterval |
how long the event stream may be silent before a heartbeat frame; 15 s by default, set it below the proxy's idle timeout |
IExecutionHistoryStore, ExecutionHistoryEntry, MisfireHistoryEntry, ExecutionHistoryQuery, MisfireHistoryQuery, ExecutionHistoryOptions, AddQuartzExecutionHistory() |
the history seam in core; IDashboardHistoryStore keeps working as its adapter in both directions |
Nothing was removed or reshaped (v4.0.1..v4.1.0 baseline diff: 96 added lines, 0 removed). The one dependency change: Quartz now depends on Microsoft.Extensions.Logging.Abstractions rather than Microsoft.Extensions.Logging; an application that took LoggerFactory or AddLogging transitively from Quartz adds the package itself (#3730, #3745).
MON/2 parses (4.0 refused it; 3.x read it as every second week) and MON-FRI/2 fires on three days, not five — neither can be reported to you, both parse; the migration guide's 4.0 → 4.1 section has the audit query. A QuartzSchedulerBuilder with a provider registered but no factory refuses to build (see #3730). GetNextInvalidTimeAfter returns null where it used to return a valid instant (4.0.1). A shut-down container scheduler injected as IScheduler re-points to the generation ISchedulerRuntime.Restart built. Mapping the HTTP API now records execution history in memory, bounded (24 h, 2,000 rows per scheduler); ExecutionHistoryOptions.MaxEntriesPerScheduler = 0 records nothing. AddQuartzHttpClient throws on a scheduler name it already registered. A 4.0 client parsing GET …/schedulers from a 4.1 host that fronts a proxy sees the origin "Remote" it does not know — upgrade the client. AddQuartzDashboard() no longer registers DashboardLiveEventsPlugin; a reverse proxy needs to forward {DashboardPath}/hub only for SignalR clients of your own, not for the pages. Log events 9100 and 9101 end with (origin …, node …) — a pipeline parsing the old template needs the new one.CheckinInterval + CheckinMisfireThreshold has passed since the row it last wrote (15 s on the defaults), and the cluster manager's sleep after a failed check-in was floored at DbRetryInterval, also 15 s, so one database blip during a check-in had a live node recovered by its peers: its next row went out at 22.5 s, 7.5 s after they had stopped trusting it. Inherited from Java Quartz. A failed check-in is now retried inside what is left of the window — half of it each time, never later than DbRetryInterval — and only once the window has closed does the ordinary back-off apply; the retries are timed from the last check-in that reached the database, not from the stamp a failed read of the state table leaves. Defaults are unchanged, so the 15-second failover latency stays. CheckinMisfireThreshold past the timer ceiling is now refused at startup, like CheckinInterval. (#3777, #3778; 3.22 carries the same change on 3.x)
DbRetryInterval as before.Shutdown(waitForJobsToComplete: false) closed the thread pool to new work before the scheduler loop had finished its iteration, so a firing the loop had already committed to the store (fired-trigger row written, trigger advanced) was refused by the pool and never ran — only RequestRecovery would ever have replayed it — and a completion reported after the store had closed was aborted, leaving the trigger BLOCKED for a peer's cluster recovery to settle. The loop is now halted and awaited before anything it depends on is torn down, and an unwaited shutdown gives the executions it dispatched two seconds to report their completions before the store closes; the waited form drains as before. On the fixture: 160 of 160 firings with a node leaving mid-run without waiting. (#3746, #3754)
Shutdown(waitForJobsToComplete: false) — the hosted service's default — can take up to two seconds longer when a job is in flight.AddJob/AddTrigger. Declared content is applied at creation, and a trigger the store already held was applied as a reschedule that deleted every fired-trigger row of the key — before Start(), so the recovery sweep found nothing to recover. ReplaceTrigger now deletes the trigger row only; unscheduling still takes the fired rows with it, and the replacement of a trigger whose job disallows concurrent execution is stored BLOCKED behind an execution still in flight rather than WAITING beside it. The clustered case had a second defect: a node's first check-in handed its own state row to recovery, and the deferral grace period judged that row by its old timestamp — the node's own record is never deferred now. A SQLite reproduction of the restart runs without Docker. (#3759)TRIGGER_ACCESS — returns nothing and throws nothing, so a cluster stopped scheduling in silence. DbLockHandler now logs event 3716 once per acquisition that outlives AdoJobStoreOptions.LockWaitWarningThreshold (30 s by default; null reports nothing), every acquisition is timed on quartz.jobstore.lock.wait.duration, and quartz.jobStore.commandTimeout — which 3.22 adds — translates to AdoJobStoreOptions.CommandTimeout where it used to be accepted and silently dropped. Neither ends the wait; a command timeout does, and the troubleshooting page says how to tell the case apart in each database and which server-side settings free the lock. (#3764, #3766)DbRetryInterval — also 15 s — so a check-in that failed at 7.5 s was next attempted after the peers had already recovered the node: fired rows deleted, recovering jobs fired again, [DisallowConcurrentExecution] no longer holding. Inherited from Java Quartz rather than a regression. While the window is open a failed check-in is retried inside it, half of what is left each time; only once it has closed does the ordinary back-off apply. Defaults and the documented 15-second failover latency are unchanged, and threshold >= DbRetryInterval is no longer a relation an operator has to know about. The 3.x configuration reference's clusterCheckinInterval default (7500, not 15000) and the missing clusterCheckinMisfireThreshold row were fixed on the way. (a5b2197)redis integration leg now runs two clustered nodes over one PostgreSQL store with RedisLockHandler as the only mutual exclusion, 250 firings across a [DisallowConcurrentExecution] job and an ordinary one, and asserts no firing doubled, none was lost, the non-concurrent job never overlapped and both nodes fired; a second fixture shuts one node down mid-run and checks the survivor and the closed multiplexer. 4.0.0 listed Quartz.Extensions.Redis as the least exercised package; it no longer is. (#3722, #3744)quartz.jobStore.* key fails startup instead of being ignored. 4.0 read the keys it knew into typed options and did nothing with the rest, so quartz.jobStore.dbRetryIntreval started the scheduler with the default in force and said nothing — which is also how quartz.jobStore.commandTimeout went missing before it was bridged. A persistent store now refuses a key under that prefix that nothing reads, by name, where a misconfiguration already fails: Unknown configuration property 'quartz.jobStore.dbRetryIntreval'. It is not a setting of the ADO.NET job store …. quartz.checkConfiguration = false allows it through, as for every other unknown key; a store of your own keeps failing in the binder that writes leftover keys onto it, as 3.x did. (#3767)
quartz.jobStore.* typo that 4.0 tolerated stops the scheduler from starting.Quartz.Cron package was sized (#3720): mechanical at ~5,000 lines on net8.0;net10.0, and not shipped — no consumer would take it today. The report is on the issue.Quartz depends on the logging abstractions, not the logging package — Quartz writes log events and never builds the pipeline that carries them, so AddQuartz registers ILogger<> and no ILoggerFactory at all; wherever it needs a factory it asks the container and falls back to LogProvider. Nine tests pin that the order of AddQuartz and AddLogging does not matter and that a host's providers are never dropped. A QuartzSchedulerBuilder handed a bare ILoggerProvider without AddLogging now refuses at Build() with the fix in the message, where it used to log through a factory Quartz no longer supplies. (#3730, #3745)Quartz.NET 4.0.1 is a maintenance release about getting to 4.0: nothing about how a trigger fires changed, the public API is untouched (the baselines
Quartz.NET 4.0.1 is a maintenance release about getting to 4.0: nothing about how a trigger fires changed, the public API is untouched (the baselines did not move), and the schema is 4.0's. Five days after 4.0.0 an audit of every public dependency-bot pull request that touched a Quartz package found two reasons an upgrade never got as far as compiling, both of them ours, both fixable in a patch — one gap in the migration guide for F# — and, found by the rc.1 security gate and moved forward, a cron calendar that could take a century to answer.
dotnet add package Quartz --version 4.0.1Microsoft.Extensions.* dependency at 10.0.11, the newest patch on the day it was built, so a project pinned at 10.0.9 or 10.0.10 hit NU1605 "detected package downgrade" before a line of source compiled. Every floor is now the lowest version of its major that the code compiles against and that carries no advisory: 10.0.0 for the framework extensions, 13.0.2 for Newtonsoft.Json (13.0.1 reflects over TimeOnly member by member and a job data map loses its seconds — a test said so), 1.15.3 for OpenTelemetry.Extensions.Hosting (the first version whose OpenTelemetry.Api carries no advisory), 3.0.0 for StackExchange.Redis and 7.0.0 for TimeZoneConverter. The library is built against those floors, so the claim is checked on every build, and a test refuses a floor that creeps up. (#3717, c9ecf89)Quartz (Quartz.Extensions.DependencyInjection, Quartz.Extensions.Hosting, Quartz.Serialization.SystemTextJson, and Quartz.Serialization.Json, whose successor is Quartz.Serialization.Newtonsoft) had no 4.0.0, so a bot that groups Quartz with any of them resolved the group to the newest version every member has — 3.20.1 — and closed the 4.0.0 pull request it had already opened as superseded, with green checks. From 4.0.1 those four ids carry an empty package at every 4.x version: no assembly, one dependency on the replacement, a readme that says to remove the reference. The migration guide's instruction stands — remove them — but the upgrade is now offered. Quartz.OpenTracing and Quartz.OpenTelemetry.Instrumentation deliberately have no such package: neither has a 4.x replacement, and an empty one would hide that. (#3717, c28f213)
CronCalendar.GetNextIncludedTimeUtc no longer walks a century one second at a time — it asked CronExpression.GetNextInvalidTimeAfter for the end of an excluded run, and that method stepped through the run one second per full cron computation; for an expression that excludes everything (* * * * * ?) the walk ran to the give-up year, roughly three billion computations, on a public member. The next non-matching instant is now read off the expression's own field sets — second, minute, hour, day, month, year — with each skip verified against the time zone's clock so a repeated fall-back hour is never stepped over. An expression that fires every second answers null, and the calendar turns that into a SchedulerException naming the expression instead of hanging. The unchanged next-fire-time path measures the same as before; the changed method is 76–86 % faster on the benchmark corpus. (#3690, a125809, 4d60904)
GetNextInvalidTimeAfter returns null where it used to return a valid instant after giving up, and a CronCalendar whose expression excludes every instant now fails fast with SchedulerException.FS0856 on IJob.Execute's arity, FS0041 on ScheduleJob overload resolution, Async.AwaitTask with a ValueTask, FS0039 for StdSchedulerFactory), each quoted with its fix, and every sample is copied from a compiled example project the build keeps honest. (#3718, 1b97b49, aff4fea)From 4.0.0: dotnet add package Quartz --version 4.0.1; nothing else. From 3.x: the 4.x migration guide is unchanged in substance; if your bot has been landing 3.20.1 "upgrades", this is the release that lets it offer 4.x.
Full changelog: v4.0.0...v4.0.1
It is a major version with extensive breaking changes and a mandatory schema migration. This page is the short form; the detail lives in the docs:
Quartz.NET 4.0 targets net10.0, is asynchronous and container-built throughout, and trims a public surface that had accumulated for a decade. It is a major version with extensive breaking changes and a mandatory schema migration. This page is the short form; the detail lives in the docs:
dotnet add package Quartznet10.0 only — no netstandard2.0 build, no Full Framework, no .config support.Quartz package; StdSchedulerFactory, quartz.config discovery and the process-global SchedulerRepository.Instance / DBConnectionManager.Instance are gone. Flat quartz.* keys still work, translated to typed options by the one component that understands them, and a misspelled key is refused with a message rather than ignored. Options are validated at startup, so a bad value fails Host.Build() with every failure listed.IJob.Execute takes a CancellationToken, IJobFactory hands out a JobScope rather than a bare instance, every public Task became ValueTask, and every asynchronous member ends with a cancellation token.QueryJobs / QueryTriggers return a PagedResult<T> whose rows already carry what a listing needs, so a dashboard over a large schema no longer pays for the whole schema. The old call shapes remain as extension methods.TriggerState.Executing says whether a trigger's job is running anywhere in the cluster; fire instances, cluster nodes, execution groups, misfires and history are listings that say which node they came from; the health check notices a node whose own check-in has stopped.IScheduler client over it (the replacement for .NET Remoting, which is gone), a dashboard, typed job input (IJob<TInput>; UsingInput(input) on a registration, ScheduleJob<TJob, TInput>(input, at) for a one-off), a retry policy a trigger carries — persisted, cluster-safe, never burning a repeat count — job execution middleware, a [JobTimeout] attribute, execution groups with per-node or cluster-wide limits, node affinity, and a firing that links back to the trace that scheduled it.0 15 10 1 * * is the 1st, not every day); a time the clocks skip fires when the gap ends; the parser refuses what it used to quietly reinterpret (1-5W, L-3 in day-of-week, MON,FRI#3, MON/2, steps of zero); the five-field Unix form and the @daily-style macros work everywhere an expression is read.CalendarIntervalTrigger with PreserveHourOfDayAcrossDaylightSavings steps in local wall-clock time and no longer drifts in zones whose delta is not a whole hour; calendars mean the local day even where midnight itself moves. Review any schedule that crosses a transition.Quartz.Aspire: builder.AddQuartzPersistentStore("quartz") turns an Aspire connection name into a configured persistent store with its telemetry and health check, with no Aspire.* dependency; a store can provision its own schema as it starts (ProvisionSchema()), safe under a cluster racing to start.MapQuartzHttpApi() and MapQuartzDashboard() refuse to start unless the endpoints carry authorization or an explicit AllowAnonymous(); a scheduler can be authorized on its own name, and every call the dashboard makes is authorized where it is made.ActivitySource("Quartz") and Meter("Quartz") (the names are public constants), nine instruments, spans on every store the same way, and every log line carrying a stable event id — 278 of them, catalogued on a generated page.Quartz declares IsAotCompatible; a canary is published natively and run against a real store on three operating systems on every pull request; the remaining string-named paths are recorded and each has a documented alternative (#3341).RAMJobStore (numbers below).JobRunShell are internal, and non-public types are sealed. If something you relied on is gone, say so in an issue; these can be reopened.net10.0 only.Quartz.Extensions.DependencyInjection, Quartz.Extensions.Hosting and Quartz.Serialization.SystemTextJson are part of Quartz; Quartz.Serialization.Json is Quartz.Serialization.Newtonsoft; Quartz.OpenTracing has no 4.x release, and OpenTelemetry.Instrumentation.Quartz produces nothing on 4.x — subscribe to the source and meter directly. The first error a mixed project produces is CS0433 (a type in both Quartz 4 and a 3.x satellite): remove the three references.StdSchedulerFactory, DirectSchedulerFactory and quartz.config are gone; AddQuartz(…) or QuartzSchedulerBuilder.Create(q => …) build a scheduler.Task → ValueTask on nearly every member, and a CancellationToken on every asynchronous one.Quartz.Spi is Quartz.Extensibility, Quartz.Simpl merged into Quartz.Impl, the Newtonsoft types left the core namespaces. A string naming an old namespace still resolves, with a warning.*Support base classes are gone (every member has a default body); a listener with a 3.x signature is refused at registration.TimeOnly and DateOnly replace TimeOfDay; TimeProvider replaces SystemTime; the semaphores are lock handlers.? in either day field) now parses, and two shapes fire on a different day than Cronos would — a bare digit in day-of-week (Quartz numbers Sunday 1) and both day fields restricted (Quartz fires on the union). The guide's second audit finds them.Everything else — every renamed member, every sealed type, every removed constant — is in the guide, with the name you would have typed.
The full list is spread over the six pre-release notes below. These change what a running cluster does without saying so, and most of them are as old as 3.x:
ResumeAll could unpause real trigger groups: it deleted the all-groups sentinel with a LIKE, and the sentinel's four underscores are wildcards.TimeProvider the scheduler was given (#3456).DailyCalendar could crawl for minutes to answer months late (#3457, #3466).[DisallowConcurrentExecution] siblings behind it (#3502); a firing whose listener notification failed was listed as executing forever; a trigger with nothing left to fire was left behind when its last firing was abandoned (#3507).ACQUIRED for good (#3673, on every 3.x version too).RAMJobStore notified its listeners inside its own lock, so a listener that touched the store deadlocked (#3472).MySQLDelegate forced an index the misfire sweep could not use.ITypeLoader of its own (#3705). Stopping the host twice at once — which host.StopAsync() during RunAsync() does — no longer throws out of RunAsync (#3701).Measured on one shared machine with other work running, the same harness on both branches (src/Quartz.Benchmark; the 3.20 half is in the tree under baseline-3x/):
| Store | MaxConcurrency |
3.20 per firing | 4.0 per firing |
|---|---|---|---|
| PostgreSQL | 10 | 9.87 ms / 136 KB | 6.18 ms / 56 KB |
| PostgreSQL | 50 | 9.21 ms / 132 KB | 6.13 ms / 53 KB |
RAMJobStore |
10 | 2.58 µs / 3.25 KB | 2.16 µs / 2.57 KB |
RAMJobStore |
50 | 2.71 µs / 3.29 KB | 1.81 µs / 2.57 KB |
The persistent-store fire path is batched and counts 1.27 commits per firing at the database. Two clustered nodes ran a mixed workload for thirty minutes on PostgreSQL and on SQL Server — every trigger family, a serial job behind an overlap detector, a retry policy, a job timeout, induced misfires, a node killed mid-run and recovered — with peak serial concurrency 1, exactly one recovery, nothing left behind and a flat heap, on the beta.1 build and again on the 4.0.0 commit itself. The upgrade from a running 3.20 cluster was rehearsed by hand through its mixed-version window on both engines, and the offline upgrade over rows a released 3.20 wrote runs on every dialect on every pull request.
Each is documented on the page that owns it; this is the list, not the explanation.
SendMailJob reads SMTP credentials out of job data when nothing is registered (Quartz.Jobs).MaxConcurrency defaults to 10 on the shared thread pool (configuration reference).HttpScheduler.Status and SchedulerInstanceId block on an HTTP round trip (HTTP client).OpenTelemetry.Instrumentation.Quartz produces nothing on 4.x, and 4.x warns at start if it is present (OpenTelemetry).TransactionScope (job stores).Quartz.Extensions.Redis (one unit and one container fixture); Quartz.Aspire under an AppHost was run by hand, not in CI.The runbook is short and every step links the page that owns it. In one breath: retarget to net10.0 and drop the merged packages; on 3.x, turn binary blobs off and run the cron audit; run schema_30_to_40_upgrade_<database>.sql while 3.x nodes are still up; configure and start the 4.0 nodes one at a time (route AddCalendar through the 3.x nodes until the last one is gone); run schema_30_to_40_indexes_<database>.sql once it is; pause any job group you relied on being paused (3.x never persisted one). A 4.0 node against a schema you have not migrated refuses to start and names the column and the script.
Everything that changed between one pre-release and the next is one appendix in the guide, build by build: If you ran a 4.0 pre-release. 4.0.0 is the rc.2 commit: between the two tags, nothing changed but this page and the site.
Task → ValueTask across the whole surface (#1964), Microsoft.Extensions.Logging in place of LibLog (#1480), the move onto TimeProvider (#2286), a job's type recorded so a process that cannot load it can still work on it (#1610) — the groundwork for #3705 — and most of the cron work 4.0 is built on: both day fields restricting together (#1980), L and LW in day-of-month and an offset from the last weekday (#1956, #1975), the guards that refuse what used to be quietly reinterpreted (#1955, #2836), and the parser's rewrite and its allocation cuts (#2508, #2003).RAMJobStore (#1379, #1393, #1403), JobRunShell (#1388), listener notification (#1385, #1586, #1600, #1768) — for configuration values verified rather than assumed (#1408), and for sealing what nobody derives from (#1470).HttpScheduler client (#1803, #1815, #1831, #1845), which 4.0's HTTP API carries forward.DbDataSource support (#2438, #2453) — which is how a trimmed application configures a store — asynchronous database transactions (#2456), and OpenTelemetry that records exceptions (#2699).QuartzRandom.Next (#1937) and the connection manager's dictionary (#1888).IJobDetail finally works.UseJobStore<T> seam.DailyCalendar's checks; @Skimmenthal13 (#2486) for NOV and DEC parsing as themselves; @philr (#2039) for extended properties surviving a change of trigger type; @OronDF343 (#2060) for blob reads on SQL Server.UsePersistentStore<T>; @GhostlyRaven (#2386, #2522) for IHostedLifecycleService; @bdovaz (#2796) for the registration overloads that take an IServiceProvider; @williamdenton (#2335) for disposing a job's scope asynchronously; @BraedonWooding (#1853) for listener registration; @saklig (#1571) for [DisallowConcurrentExecution] on the configurator; @asherber (#1592) for TryGet* and @perringaiden (#2606) for IReadOnlyDictionary on JobDataMap; @tonyqus (#1613) for property access in place of reflection; @adamsitnik (#2312) for less BinaryFormatter; @AmirShitrit (#2792) for the scheduler's own TimeProvider reaching its waits; @unageek (#2788) for Interrupt reaching every job it identified; @alanblack (#2004) for the interrupt monitor reading the merged map; @dima-zhemkov (#1995) for what the diagnostic listener is handed.main while 4.0 was being written, and everyone who commented on the 4.0 roadmap (#988) over the years it stayed open.3.22.4: a misfire failure counts once per misfire threshold, only in …
3.22.4: a misfire failure counts once per misfire threshold, only in …
One fix for the 3.x line, additive: no public API change and no schema change. It is a port of a fix found while building Quartz.NET 4.3.
One fix for the 3.x line, additive: no public API change and no schema change. It is a port of a fix found while building Quartz.NET 4.3.
JobStoreSupport.TriggersFired recorded a non-transient error while firing one trigger as that trigger's failure, and committed the batch anyway. On SQL Server and MySQL the commit kept the failed trigger's partial writes, which could leave its [DisallowConcurrentExecution] job's other triggers BLOCKED for good. On PostgreSQL the error aborted the transaction, so the commit silently rolled back triggers already reported fired: their jobs ran while the store still held them as reserved, and could run again after recovery. The failing attempt now rolls back whole, and the batch fires again without that trigger, which is reported failed. Nothing changes when nothing fails. (#3931, #3944)The same fix ships for 4.2 in 4.2.4.
Full Changelog: v3.22.2...v3.22.3
One fix for the 3.x line, additive: no public API change and no schema change. It is a port of a fix found while building Quartz.NET 4.3.
One fix for the 3.x line, additive: no public API change and no schema change. It is a port of a fix found while building Quartz.NET 4.3.
[DisallowConcurrentExecution] job running on another node. When two nodes reserved triggers of the same such job, the node that lost released its trigger back to WAITING, though the job was still running. With one trigger acquired at a time, which is 3.x's default, that trigger then sat at the head of the node's queue. It was skipped on every pass, it hid the triggers due behind it, and the node slept its whole IdleWaitTime (30 s by default). JobStoreSupport now keeps a released trigger BLOCKED while its job runs, and acquisition reads past a trigger it has to skip. Nothing was lost or doubled. (#3926, #3936)The same fix ships for 4.2 in 4.2.3.
Full Changelog: v3.22.1...v3.22.2
Four fixes for the 3.x line, all additive: no public API change and no schema change. They are ports of fixes found while building Quartz.NET 4.3, plu
Four fixes for the 3.x line, all additive: no public API change and no schema change. They are ports of fixes found while building Quartz.NET 4.3, plus one found while proving them.
Standby() has returned. The scheduler's loop checked whether it was paused before draining its wake-up signal, so a Standby() that landed between the two, most likely while the loop was waiting for a free worker on a busy scheduler, was lost. The round then went on to fire a trigger due within the next IdleWaitTime (30 s by default). (#3908)IdleWaitTime late, or went through misfire handling. The same overwrite meant Standby() followed by ScheduleJob() could fire a trigger the loop was holding. (#3908)TRIGGER_ACCESS. UpdateLockRowSemaphore, UpdateLockRowSemaphoreMOT and StdRowLockSemaphore now give up, and JobStoreSupport retries the operation in a fresh transaction. (#3908)DirectSchedulerFactory.CreateScheduler returns a scheduler whose job store has finished initializing. It started the store's Initialize without waiting for it. A call made before Start() could then run while the store was still probing its optional columns, and fail reading a trigger (IndexOutOfRangeException: MISFIRE_ORIG_FIRE_TIME). A store that fails to initialize now fails CreateScheduler instead of leaving a broken scheduler registered. (#3908)Full Changelog: v3.22.0...v3.22.1
Quartz.NET 3.22.0 carries three fixes to the persistent store's recovery and clustering paths, each found on the 4.0 line and ported here. One of them
Quartz.NET 3.22.0 carries three fixes to the persistent store's recovery and clustering paths, each found on the 4.0 line and ported here. One of them adds a setting the store never had — a timeout on the statements it issues — which is the public-surface addition that makes this a minor rather than a patch. The schema is 3.20's. Two of the changes alter behaviour, each marked Behavior change worth noting below.
dotnet add package Quartz --version 3.22.0AddJob/AddTrigger and the store already holds them. The declared trigger was applied as a reschedule, which went through ReplaceTrigger and deleted every fired-trigger row of the key — before Start(), so by the time the first cluster check-in or the non-clustered sweep looked for the interrupted execution, its row was gone. A replacement is not a removal: ReplaceTrigger now deletes the trigger row only, and unscheduling still takes the fired rows with it. The replacement of a trigger whose job disallows concurrent execution is stored BLOCKED behind an execution still in flight, as any trigger of that job is, where it used to be stored WAITING and could fire alongside. A fast restart under a stable instance id had a second defect: on its first check-in a node handed its own state row to recovery, and the deferral grace period judged that row by its old timestamp, so the EXECUTING row of a serial job was preserved for a second detection that never came — the node's own record is never deferred now. (#3759, port of e60bd26)
BLOCKED while an execution is in flight.quartz.jobStore.commandTimeout bounds every statement the store issues, the lock statement included. A row lock belongs to the database session that took it, so a node that loses its network without the server noticing keeps TRIGGER_ACCESS locked, and every other node's next lock statement queues behind that dead session — and nothing on this branch bounded the wait: the statement never failed, so the lock handler's retries, DbRetryInterval back-off and the SchedulerError notification never ran, and the cluster stopped firing without logging anything (#3763). The setting is a millisecond count like every other duration on the store; 0 means the provider's own default (30 seconds for most), a negative value is refused where it is configured, and a value past what ADO.NET can hold in whole seconds is refused for the same reason. It is rounded up to whole seconds, because rounding down would turn a sub-second value into "wait forever". It reaches the driver delegate through DelegateInitializationArgs and the lock handler through DBSemaphore.CommandTimeout, which the store writes once its handler is known — so a handler named by quartz.jobStore.lockHandler.type is bounded too. An unconfigured store imposes nothing. SchedulerBuilder's persistent-store options set it fluently. The troubleshooting page gains the dead-session case: how to tell it apart in each database, why killing a process does not reproduce it, and the server-side settings that are the only thing that frees the lock. 4.x has the same setting as JobStore:CommandTimeout. (#3764, #3765)DbRetryInterval, also 15 s, so a check-in that failed at 7.5 s was next attempted after the peers had already recovered the node: its fired-trigger rows deleted, its recovering jobs fired again, [DisallowConcurrentExecution] no longer holding. Java Quartz sleeps the same way, so this is inherited rather than a regression. While the window is still open a failed check-in is now retried inside it — half of what is left each time, never later than DbRetryInterval, never sooner than the loop's pause — and only once it has closed does the ordinary back-off apply. The manager times the window from its own record of the last check-in that reached the database, not from the store's LastCheckin, which a failed read also stamps. Defaults and the 15-second failover latency are unchanged, and threshold >= DbRetryInterval is no longer a relation an operator has to know about. The configuration reference's clusterCheckinInterval default (7500, not 15000) and the missing clusterCheckinMisfireThreshold row were fixed on the way. (port of a5b2197)
DbRetryInterval while the peers' window is still open.JobStoreSupport.CommandTimeout, DBSemaphore.CommandTimeout, AdoUtil.CommandTimeout, DelegateInitializationArgs.CommandTimeout, SchedulerBuilder.PersistentStoreOptions.CommandTimeout — the one setting, wherever a statement is prepared.dotnet add package Quartz --version 3.22.0. Nothing to migrate; the schema is unchanged. The 4.x line is the current major and carries all three fixes as well; the 4.x migration guide is the way there.
Full changelog: v3.21.0...v3.22.0
Quartz.NET 3.21.0 carries the three fixes held back from 3.20.1 because each needed a small addition to the public surface or changed what a running s
Quartz.NET 3.21.0 carries the three fixes held back from 3.20.1 because each needed a small addition to the public surface or changed what a running scheduler does. All three were found while 4.0 was being finished; each is as old as 3.x. The public API grows by two interfaces on one class and nothing else; the schema is 3.20's. Three of the changes alter behaviour, each marked Behavior change worth noting below.
dotnet add package Quartz --version 3.21.0ResumeAll clears every paused trigger group, not only the ones with triggers — the persistent store resumed the groups it found in QRTZ_TRIGGERS and then deleted only its all-groups marker, so a group paused while it held no triggers kept its QRTZ_PAUSED_TRIGGER_GRPS row and went on pausing whatever was scheduled into it afterwards. Pausing a group before anything is scheduled into it is a documented use of the exact-name matcher, so this was a row the store wrote on purpose and could not take back. The trailing delete now takes every group, as RAMJobStore has always done. (#3721, #3742, port of f76b04a)
ResumeAll.RedisSemaphore opened a ConnectionMultiplexer on the first lock and kept it, with its heartbeat, for the life of the process, because nothing on the store's shutdown path reached the lock handler and ISemaphore had no member that meant "we are done". On a branch that targets netstandard2.0 and net462 an interface cannot gain a default member, so JobStoreSupport.Shutdown now disposes a lock handler that implements IAsyncDisposable or IDisposable, after the misfire handler, the cluster manager and the connection manager have stopped, logging and continuing if that throws; RedisSemaphore implements both and closes the multiplexer it opened. (#3721, #3742, port of #3639)
ISemaphore that implements either interface is now disposed at shutdown.Task.Delay refuses anything longer than about 49.7 days on .NET and about 24.9 days on .NET Framework, with an ArgumentOutOfRangeException naming a parameter called delay, and every duration Quartz waits out that way was accepted unchecked and reported later from wherever the wait happened. MisfireHandlerFrequency, MisfireThreshold (when it is also the handler's sleep), ClusterCheckinInterval, DbRetryInterval, TransientRetryInterval, the row-lock handlers' RetryPeriod, StartDelayed's argument and QuartzHostedServiceOptions.StartDelay now name the setting, the ceiling and the value at configuration time. The ceiling is per target framework, held to what Task.Delay actually accepts by a test. (#3721, #3742, port of #3577)
Quartz.Extensions.Redis: RedisSemaphore implements IAsyncDisposable and IDisposable.dotnet add package Quartz --version 3.21.0. Nothing to migrate. The 4.0 line is the current major; the 4.x migration guide is the way there, and 4.0.1 made the upgrade one a dependency bot can offer.
Full changelog: v3.20.1...v3.21.0
Quartz.NET 3.20.1 is a maintenance release: every change is a bug fix, the public API is untouched (the baselines did not move), and the schema is 3.2
Quartz.NET 3.20.1 is a maintenance release: every change is a bug fix, the public API is untouched (the baselines did not move), and the schema is 3.20's. Most of it was found while 4.0 was being finished and rehearsed — a fix that turned out to be as old as 3.x was ported here rather than left on the newer line — and one item comes from a production application's 3.19.1 → 4.0 upgrade that also read on 3.x. Eight of the fixes change what a running scheduler does, each marked Behavior change worth noting below.
dotnet add package Quartz --version 3.20.1Landed on the branch since 3.20.0:
DailyTimeIntervalTrigger stored through the default Newtonsoft path reads back again — TimeOfDay has no parameterless constructor, so with the trigger converters off (the default) EndTimeOfDay threw "Unable to find a constructor" and StartTimeOfDay silently read back as midnight. A converter scoped to TimeOfDay-typed members reads both forms; nothing about what is written changed, so every blob a released 3.20 wrote is one this reads. (9ee33fec17, fixes #3508)StartTimeUtc kept its milliseconds while the fire times are counted in whole seconds, so a start of 22:50:00.68 could produce a first fire at 22:50:00.000. Start and end are rounded down to the second when set, as CronTriggerImpl always did. (cc051a7788, #3386)RAMJobStore and as a permanent COMPLETE row in the ADO store. Both stores finish it now. (0af9431d3e, #3507)[DisallowConcurrentExecution] job is neither acquired nor swept, so the completion that unblocks it is the first thing that can settle its missed fire time; RAMJobStore now does what JobStoreSupport.RecoverUnblockedMisfires always did. (c9d8658a35, #3463)RAMJobStore wrote Paused over Error, so a failed trigger vanished from every listing once its group was paused and ResetTriggerFromErrorState had nothing to reset. It now pauses only what the ADO store pauses: waiting, acquired and blocked triggers. (a56a16ca0c)
RescheduleJob advanced a never-fired repeating simple trigger's start time past a next fire time it kept, so it fired at the stale time and again at its start. (3e086091fc, #3554)overwrite-existing-data on, and a repeating trigger that starts now fired twice milliseconds apart. (f25080cef6, #3554)Dictionary<string, string> job-data value written by the Newtonsoft package carried a $type the System.Text.Json reader handed back as an entry, and one written by System.Text.Json came back from Json.NET as a JObject. Both readers read both shapes; neither writer changed. (83ba80ce79, part of #3582)QRTZ_SIMPROP_TRIGGERS too — a database missing only that table passed validation and failed on the first calendar-interval, daily-time-interval or recurrence trigger insert. (b33c70487b, #3564)4b3c43a90e); untagged builds from the branch say 3.20 (0e2f6bcf31); the XML scheduling integration test opens its own fixture's data source (4c07210199, #3573).Ported from 4.0:
JobToBeExecuted escaped as itself rather than the exception the run shell catches, so TriggeredJobComplete was never reached: the trigger stayed acquired and, for a [DisallowConcurrentExecution] job, every sibling trigger stayed blocked; the firing was also listed as executing for the life of the process. (port of #3502)IDX_QRTZ_T_NFT_ST_MISFIRE, whose second column is compared with <> and stops the seek dead. Measured on 4.x against 100,000 triggers: the count 111 ms → 0.7 ms, the sweep 66 ms → 0.7 ms. No schema change. (port of #3608)RescheduleJob and UpdateTriggerDetails on the ADO store resolved the job's class to decide whether the new trigger could run, and failed in an administration node without the assembly. Both read the job's two attribute flags from QRTZ_JOB_DETAILS now, so the decision is right without the class and a placeholder ITypeLoadHelper — which decided that question by whether the placeholder carried the attribute — is no longer needed. (port of #3705)40001, 40P01; 40002 excepted) is transient. Firebird reports a write conflict that way with IsTransient false, and MySql.Data its 1213 deadlock. (port of #3454)
SimpleTrigger interval finer than a millisecond was stored as 0, read back as zero, and left the trigger in ACQUIRED for good behind a divide-by-zero the store logged and swallowed. It is refused on write now, naming the trigger and the column; RAMJobStore keeps accepting it. (port of #3673)
List<string> or a nested object serialized happily and threw on the next read with the blob already in the database, and every later acquisition of the trigger failed on it. A value that would be stored as a JSON array, or as an object other than a Dictionary<string, string>, is refused before the first byte is written, naming the entry and its type. Anything stored as a number or a string — every numeric type, DateTime, Guid, byte[], Uri — still round-trips exactly as before. (port of #3495)
JsonSerializationException at store time, where it used to be a blob the next read failed on. The refusal also covers three shapes that did not throw before but never came back as themselves either — a non-generic Hashtable, an object whose properties are all strings, and a JobDataMap nested inside a JobDataMap — each of which the reader handed back as a Dictionary<string, string>. Store one of those as a string of your own making.EndTimeUtc falling between two fire times of the same day let the trigger go on firing until the daily window closed, and FinalFireTimeUtc reported that close even when it was a day past the end. (port of the daily half of #3458)
NativeJob no longer deadlocks a child that writes more than a pipe buffer — both streams were redirected whether or not consumeStreams asked for them to be read, so with the defaults a chatty process blocked on its own write and the job's synchronous wait held a worker for ever. Nothing is redirected unless consumed. (port of the rc.1 fix)EnlistConnection inside a TransactionScope took a Microsoft.Data.Sqlite connection on trust, and SQLite cannot enlist, so every statement committed on the spot and a rolled-back scope left the schedule behind. EnlistTransaction(DbTransaction) still works there. (port of the beta.1 fix)now - MisfireThreshold was a misfire to RAMJobStore and to the ADO store's single-trigger path but not to its periodic sweep; the sweep says <= now and the acquisition predicate moved to > in step. (port of #3462)
"3,14" read as 314 from the floating-point accessors while GetInt threw. (port of the beta.1 fix)
GetDouble and GetFloat throw a FormatException for such a string where they used to answer a number a hundred times too large.[ matches literally on SQL Server — T-SQL reads [ as a character class in LIKE; it is escaped on that dialect only, because the standard forbids escaping a non-wildcard elsewhere. (port of the rc.1 fix)
[ matches the groups it names rather than the character class T-SQL read it as, so it can list, pause, resume or delete a different set than on 3.20.DirectoryScanJob stores its previous scan as something a job store can write — it kept a List<FileInfo> under [PersistJobDataAfterExecution], which System.Text.Json cannot write, so its first firing on such a store failed to persist. A legacy list already in a running scheduler is still read. (port of the rc.1 fix)DirectoryScanJob runs at all on 3.20 — it read its optional job-data keys with GetString, which throws for a key that is not there, so a job configured without a directory provider or listener name failed on every firing with KeyNotFoundException. Found while porting the previous item; the optional keys are asked for rather than read.SelectSchedulerStateRecords binds its parameters in statement order — only a provider with BindByName off could ever have noticed. (port of the alpha.3 fix)Unchanged. No signature was added, altered or removed, and the PublicApiTest baselines did not move.
A drop-in upgrade from 3.20.0: no schema change, no configuration change, no migration script. Read the Behavior change worth noting bullets above — each is a case that used to be silently wrong and is now either correct or loud.
Quartz.NET 4.0 was released on 2026-09-03. It targets net10.0 only; 3.x remains the line for netstandard2.0 and .NET Framework, and fixes that apply to both keep landing on both, which is what most of this release is. The migration guide is the map if you are considering the move.
Full changes: v3.20.0...v3.20.1
Quartz.NET 3.20.0 is a feature release: the ADO.NET job store can take part in the application's own database transaction, job instantiation failures
Quartz.NET 3.20.0 is a feature release: the ADO.NET job store can take part in the application's own database transaction, job instantiation failures finally carry the trigger they died on, and the daylight-saving and calendar arithmetic got a systematic pass that fixed several defects a schedule can actually hit. The public API is additive only — three new members and one new exception type, nothing changed or removed — but this is not quite a drop-in upgrade. Four things to read before you take it:
database/ that used to sit flat is now database/migrations/<version>/<name>_<dialect>.sql, one runnable file per database instead of one file with five commented-out dialect blocks. Old links still resolve against release tags; the mapping is below.database/migrations/3.20/). Nothing needs it to run, but PostgreSQL users should read that bullet.quartz.jobStore.acceptEnlistedTransactions or AcceptEnlistedTransactions(), then hand the store a connection for the duration of a scope with IScheduler.EnlistTransaction(DbTransaction) or IScheduler.EnlistConnection(DbConnection). The application owns the commit; the job store neither commits nor rolls back, and the enlistment flows with the current asynchronous context the way Transaction.Current does — which is what makes it work while IJobStore is a singleton and a DbContext is scoped. Nothing about it is EF Core specific. Taking part always means handing over a connection: an ambient TransactionScope alone is not enough, because a connection the job store opens for itself would be a second connection in that transaction and would force promotion to a distributed one — which needs MSDTC (Windows only, and on modern .NET also an explicit opt-in through TransactionManager.ImplicitDistributedTransactions) and is impossible on Npgsql, which has no distributed transaction support at all. Sharing the one connection is what keeps the transaction local. Opt-in, because joining the application's transaction changes when, and whether, scheduling commits; JobStoreCMT is untouched, since running inside a container-managed transaction is that store's whole contract. (#3204, fixes #2038)
txIsolationLevelSerializable applies only to the job store's own connections.IJobFactory cannot produce a job the trigger has already fired and been committed, but there is no IJobExecutionContext yet, so no trigger or job listener can be raised and ISchedulerListener.SchedulerError is the only notification. It carried the job key as interpolated message text and nothing else, leaving callers parsing a string to find out which firing died. The new Quartz.Core.JobInstantiationException : SchedulerException carries Trigger, JobDetail and FireInstanceId — the same shape JobExecutionProcessException has for execution-time failures — and both catch blocks in JobRunShell.Run raise it, so the DI and non-DI paths are enriched alike. Message text is byte-identical in both paths, including a long-standing misplaced quote, so anything parsing it today keeps working while it migrates. (#3215, closes #3213)
SchedulerException path the exception handed to listeners is now a JobInstantiationException wrapping the factory's exception rather than being it; the original is reachable as InnerException. The choice between NoInstruction and SetAllJobTriggersError still tests the original exception, so cancellation and disposal races behave exactly as before.TimeZoneInfo.GetUtcOffset returning the pre-gap offset for positive-delta zones. It is now two internal TimeZoneUtil helpers — ResolveLocal (ambiguous → the first/daylight occurrence; nonexistent → paired with the pre-gap offset, found by scanning backwards a minute at a time so 30-minute deltas and negative-delta zone models work) and WalkToGapEnd — with all five call-site families migrated onto them. Two defects fell out: a negative-daylight-delta gap hazard, where a zone modelled with a negative delta (Europe/Dublin on TZif data, so Linux and macOS) produced an instant before the gap — a scheduler hot-loop hazard the ambiguity demotion does not cover; and a sub-second demotion defect, where CronExpression.GetTimeAfter compared its whole-second candidate against the untruncated after-time, so a sub-second after-time inside the repeated fall-back hour demoted a valid fire a whole hour forward. The migrations are behaviour-neutral for every positive-delta zone, guarded by a minute-by-minute differential test across ±3 hours of all eight test zones' transitions. (#3198, building on #3195)
GetTimeAfter monotonic in its argument again, and stops GetTimeBefore returning instants the expression never fires at. Fire times inside the repeated fall-back hour can therefore differ from 3.19.DailyTimeIntervalTrigger no longer fires past its EndTimeUtc — the day-of-week advance resolved the advanced day's start-of-day through the instant-based offset overload while every sibling call site used the wall-clock policy overload, so the offset carried through the AddDays walk was stale once the walk crossed a transition, and an in-gap start-of-day resolved one transition delta too early. The EndTimeUtc check inside the same method consumed the mis-resolved instant before the downstream rescue corrected it, so with an end time between the wrong instant and the right one the trigger fired once past its configured end. Only triggers with a partial DaysOfWeek set were affected — the existing tests all use a full week, where the two resolutions coincide. (#3196)HolidayCalendar, AnnualCalendar, MonthlyCalendar and WeeklyCalendar built the boundary they walk as midnight at the offset the queried instant happened to carry, and advanced it with AddDays, which keeps that offset. Both assume a local day begins at midnight at the same offset the rest of the day carries, and a transition day is exactly the day where it does not. In America/Santiago, where the clocks move at midnight, the 23-hour day (2019-09-08) answered with the excluded instant it was asked about, and the 25-hour day (2019-04-06) overshot by 23 hours; in a zone that moves its clocks later in the day the answer lands a whole date out (Europe/Helsinki on 2024-03-31, asked from the afternoon, answered 2024-03-30); and a run of excluded days crossing a transition drifts by the transition delta. The boundary now resolves local midnight by naming the date rather than by adding twenty-four hours. CronCalendar never had the shape. (#3478, fixes #3457)DailyCalendar.GetNextIncludedTimeUtc is jumped to rather than walked up to — it named the window's edges on the date the argument fell on and paired them with the argument's offset, while IsTimeIncluded converts into the calendar's zone first, so a question asked in UTC about a calendar keeping another zone's hours had the two disagree. The jump then landed on a window an offset away from the one being tested, the loop fell through to its last resort — one precision step at a time, bounded only by where the wrong window happened to end — and it could step clean over an included stretch. The measured reproduction, an inverted 21:00–22:00 America/Santiago calendar asked at 2019-04-07T01:30Z: 2019-09-09T00:00:59.999Z, five months late, reached a minute of wall clock at a time, against 2019-04-08T01:00Z in two passes now. The whole daily-calendar half of the new test fixture takes 1 m 52 s against the unfixed file and milliseconds against this one. The answer is now named from the window's own edges — the instant the day opens at, the second reading of that edge where the clock repeats it, the instant the clocks moved where a fall-back takes the clock back to before the window opened, and the day's own first instant — each checked against the calendar's own rule before it is taken. (#3478, fixes #3466)
precisionStepMillis optimisation (#2285) is removed, because a jump has nothing left to speed up and it was rounding the answer up by as much as a minute. GetNextIncludedTimeUtc answers are now exact: a 06:00–22:00 calendar asked at 21:59Z returns 22:00:00.001Z where it returned 22:01:00.000Z, and asking about an instant the calendar already includes gives back the next millisecond rather than the next minute. The NestedCalendarTests performance test that came with #2285 is unchanged and passes.GetTimeRangeStartingTimeUtc and GetTimeRangeEndingTimeUtc are public and carried the same offset assumption in public — asked in UTC about a Santiago calendar they answered about the UTC date at +00:00, which no caller can use. They now read the date of the local day the instant falls in. For a calendar left on the default TimeZoneInfo.Local asked with a local value, the conversion is a no-op. No signature changed.UseNewtonsoftJsonSerializer leaves RegisterTriggerConverters off by default, and with it off Json.NET's default contract wrote a TimeZoneInfo as its whole public surface (Id, DisplayName, BaseUtcOffset, all read-only), so reading it back set nothing and the trigger's getter fell through to TimeZoneInfo.Local. A trigger stored under Tokyo fired on whichever zone the reading machine was in, silently. Measured before the fix: CalendarIntervalTriggerImpl, RecurrenceTriggerImpl and CronTriggerImpl all came back on the reading machine's zone. An internal TimeZoneInfoConverter writes the id and reads both the id and the old object form, attached per property to members typed as a TimeZoneInfo — deliberately not on the serializer's converter list, since that list is consulted for a value's runtime type wherever it appears and a zone held in a job data map value would then lose the $type that path carries. The four private timeZoneInfoId helpers that were meant for this and never worked (DefaultContractResolver does not serialize private members) are gone. BLOB_TRIGGERS payloads written by BinaryObjectSerializer are unaffected: they are computed properties, so there was never a backing field for BinaryFormatter to match. (#3505)
DailyTimeIntervalTriggerImpl cannot be read back at all — TimeOfDay has no parameterless constructor, so Json.NET fails loudly on EndTimeOfDay. It predates this change, it fails rather than corrupting, and the ADO store reaches that path only for a trigger it serializes as a blob, so a shipped DailyTimeIntervalTrigger does not go through it.JobInterruptMonitorPlugin interrupted by job key, so a monitor that elapsed cancelled every running execution of that job rather than the one it was watching. Worse, a vetoed fire's monitor was never cancelled at all, because the only cleanup point was TriggerComplete, which a vetoed fire never reaches — so every veto leaked a live monitor that would later interrupt an unrelated, healthy execution of the same job. It now calls Interrupt(fireInstanceId), cancels the monitor on veto through a job listener of its own, does not start a second monitor for a re-executed job sharing a fire instance id, and removes its own bookkeeping entry when it elapses. Both scenarios are exactly as analysed in the report. (#3249, fixes #3248)
IScheduler.Interrupt(fireInstanceId) now raises ISchedulerListener.JobInterrupted, matching the Interrupt(JobKey) overload — relevant to anyone calling the fire-instance overload directly. And AutoInterruptable is now read from the merged job data map, consistent with MaxRunTime, so a trigger's data map can opt a fire in or out of auto-interruption rather than only override the timeout.CALENDAR_NAME was written as '' rather than NULL stopped firing entirely: every job store gates its calendar lookup on CalendarName is not null, so the empty string passed the gate, the lookup found nothing, and the fire was silently dropped. AbstractTrigger.CalendarName now stores a blank name as null, without trimming — the name is a lookup key against whatever AddCalendar stored, so trimming " holidays " would break a calendar registered with padding and create the same bug from the other direction. That one setter is the choke point for TriggerBuilder, both JSON converters and the ADO read-back, so databases already holding '' self-heal: the row rehydrates as "no calendar", the trigger fires again on the next acquisition, and the column is written back as NULL next time it is persisted. No migration script. TriggerDetailsUpdate.WithCalendarName normalizes separately, since both stores check the calendar exists before the value reaches the trigger setter. Both stores now also log when they skip a fire over a missing calendar, which turns this class of report from a mystery into a one-line diagnosis. The dashboard, which produced the empty string by rebuilding the trigger field by field out of its display projection, now edits the trigger JSON it was already handed — which also preserves the node pin and the real trigger type it used to drop. (#3295, fixes #3294)CronCalendar.GetNextIncludedTimeUtc never terminated from an excluded instant (it advanced with GetNextValidTimeAfter, which lands on another excluded time); CronCalendar's three-argument constructor dropped its timeZone, so the calendar evaluated its exclusion cron in machine-local time; transient-error classification short-circuited on DbException.IsTransient, which on any .NET 6+ host matches every driver exception — so the deadlock-1205 list, the SQLite busy/locked check and the timeout fallback were all dead code and retryable failures were classified permanent; a prefix trigger-group pause paused only the first matching group and a prefix resume could never clear what a prefix pause recorded; JobInterruptMonitorPlugin silently ignored a numeric MaxRunTime, falling back to the five-minute default with no log; and Newtonsoft deserialization populated read-only collections through their getters, so on the default configuration a DaysOfWeek subset came back as all seven days. (#3334)
DaysOfWeek subset survives the plain Newtonsoft round trip. Deliberately not backported: the 4.x store-parity semantic alignments (pause-over-Error, ResumeAll marker clearing, sentinel visibility, exception re-wrap types), which change observable maintenance-branch behaviour.SelectInstancesFiredTriggerRecords was the only fired-trigger reader that never read PRIORITY and ClusterRecover assigns from exactly that reader, so every recovery trigger ran at priority 0, below the default 5, deprioritising recovery work precisely when a node has died. Four IDriverDelegate members failed on every provider because their bound parameter names never matched their SQL; nothing in the scheduler calls them, which is why it never surfaced, but they are public surface reachable from JobStoreSupport subclasses. And group matcher values are now escaped, with ! as the escape character because ESCAPE '\' is a MySQL syntax error while ! is a plain literal on all six supported databases. (#3202)
% or _ now match literally in group matcher queries. Previously those characters acted as LIKE wildcards, so a matcher could list, pause, resume or delete groups it was not meant to match. The same correction means ResumeAll deletes only the _$_ALL_GROUPS_PAUSED_$_ sentinel row rather than a pattern in which all eight underscores were single-character wildcards.sched_name, which every Quartz statement filters on first, and idx_qrtz_t_nft_st had its columns reversed — (next_fire_time, trigger_state) against an acquire query that is two equalities then a range, so the index could never bound the scan on state or scheduler name. This is the shape behind slow-acquire-on-PostgreSQL reports. Verified on live PostgreSQL 16: the upgrade script and a fresh install converge on byte-identical pg_indexes sets, and a 160,000-trigger acquire-plan comparison moves all three predicates from post-filter into the index condition — buffer hits 136 → 57, discarded rows 61 → 0. Across the other dialects, indexes whose columns are a leftmost prefix of a wider same-table index are dropped, with the coverer named on every drop. IDX_QRTZ_J_GRP deliberately stays on 3.x, unlike 4.x, because its 4.x coverer does not exist here and it serves the group listings. The migration is optional and performance-only — database/migrations/3.20/index_alignment_<dialect>.sql. (#3203)database/migrations/<version>/<name>_<dialect>.sql, one directly-runnable file per database instead of one file carrying five commented-out dialect blocks, with every statement guarded so re-running is a no-op (SQLite ADD COLUMN is the documented exception — SQLite has no conditional DDL). The scripts are generated from one description in the build, because six hand-written dialect variants is how they drift, and VerifyMigrations fails a checked-in script that no longer matches. database/README.md is the new index: run order, per-version status, and the old-path → new-path map reproduced below. The migrations previously had no test coverage at all; MigrationScriptTest now builds a 3.16-era schema from a checked-in baseline, applies 3.17 → 3.18 → 3.19 → 3.20 in order, applies each twice so the guards are exercised, and asserts the result matches what the current tables_<dialect>.sql produces, table for table, column for column, index for index — on all six databases. It caught two real defects on the way: PostgreSQL's index alignment used CREATE INDEX IF NOT EXISTS for three indexes that already exist in a 3.16 schema under the same name with different columns, so the guard silently kept the wrong shape — including idx_qrtz_t_nft_st; and MySQL's QRTZ_BLOB_TRIGGERS carried an InnoDB-auto-named inline index duplicating its primary key that the migration had left in place. It also backports the 2.5→2.6 QRTZ_CRON_TRIGGERS.TIME_ZONE_ID fix (#1985), which never reached this branch. (#3219, fixes #3218)main, and this branch links to them — they were briefly mirrored here, and a mirror goes stale silently every time 4.x's schema moves. A confidently wrong upgrade script is worse than one that is plainly somewhere else, so database/migrations/4.0/ is not on this branch and database/README.md points at https://github.com/quartznet/quartznet/tree/main/database/migrations/4.0 in every place it used to link to the folder. (#3373, and #3326, which stopped the SQLite 4.0 upgrade claiming it can be re-run when its five ADD COLUMNs are unguarded)git diff (#3226). The UnitTest target now asks each project which frameworks it declares and runs it once per framework: dotnet test --framework X against a project that does not target X exits 0 having run nothing, so the Ubuntu and macOS legs had been running 116 tests and silently skipping the 1,753 in Quartz.Tests.Unit while the green check said otherwise (#3228). CI was unbroken by moving to Testcontainers 4.14.0, which references a patched SSH.NET, and by pinning NuGet.Frameworks forward for SDK 10.0.400 (#3278). The build orchestrator moved to Fallout 10.4.0 (#3252), changelog.md was retired in favour of the GitHub releases that had already superseded it (#3224), sonar-project.properties was removed because SonarCloud's automatic analysis never read it (#3221), and a test that had been writing files into the application directory on every CI run stopped (#3293).Additive only. No existing signature changed, nothing was removed, and the new baselines are byte-identical across every other change in this release.
Quartz.Core.JobInstantiationException : SchedulerException, carrying Trigger, JobDetail and FireInstanceId (#3215)IScheduler.EnlistTransaction(DbTransaction) and IScheduler.EnlistConnection(DbConnection), plus AcceptEnlistedTransactions() on the persistent store builder and the quartz.jobStore.acceptEnlistedTransactions key (#3204)The scripts used to sit flat in database/, with the dialects other than SQL Server commented out inside each file. Those paths are gone from the branch tip. Old links keep working against release tags — for example https://github.com/quartznet/quartznet/blob/v3.19.1/database/schema_30_add_preferred_node.sql.
| Old path | New path |
|---|---|
database/sqlserver_schema_10_to_20_upgrade.sqldatabase/schema_10_to_20_upgrade.sql |
migrations/2.0/schema_10_to_20_upgrade_sqlServer.sql |
database/schema_20_to_22_upgrade.sql |
migrations/2.2/schema_20_to_22_upgrade_<db>.sql |
database/schema_25_to_26_upgrade.sql |
migrations/2.6/schema_25_to_26_upgrade_<db>.sql |
database/schema_26_to_30.sqldatabase/schema_26_to_30_upgrade.sql |
migrations/3.0/schema_26_to_30_upgrade_sqlServer.sql |
database/schema_30_add_misfire_orig_fire_time.sql |
migrations/3.17/add_misfire_orig_fire_time_<db>.sql |
database/schema_30_add_execution_group.sql |
migrations/3.18/add_execution_group_<db>.sql |
database/schema_30_add_preferred_node.sql |
migrations/3.19/add_preferred_node_<db>.sql |
database/schema_30_drop_redundant_indexes.sqldatabase/schema_30_postgres_index_realignment.sqldatabase/schema_30_sqlite_indexes.sql |
migrations/3.20/index_alignment_<db>.sql |
database/schema_30_to_40_upgrade.sql |
migrations/4.0/schema_30_to_40_upgrade_<db>.sql on main |
database/tables/ did not move — it is the only database/ path referenced from code.
One narrow case is not safe to mix across versions. If all of these are true — you use UseNewtonsoftJsonSerializer with RegisterTriggerConverters off, which is the default; and you schedule a custom trigger type that the ADO store persists as a blob because it has no persistence delegate — then a 3.20 node writes that trigger's time zone as a bare id string, which is the point of the fix, and a 3.19 node reading the same row cannot parse it. 3.20 reads both the new id form and the old object form, so the other direction is fine. Upgrade every node before scheduling such a trigger. Every shipped trigger type goes through a persistence delegate rather than this path, and RegisterTriggerConverters = true was never affected, so most deployments have nothing to do here.
Full Changelog: v3.19.1...v3.20.0
Quartz.NET 3.19.1 is a small bug fix release with two targeted fixes: DailyTimeIntervalTrigger no longer gets stuck in an infinite fire loop on DST sp
Quartz.NET 3.19.1 is a small bug fix release with two targeted fixes: DailyTimeIntervalTrigger no longer gets stuck in an infinite fire loop on DST spring-forward days, and StdSchedulerFactory.GetScheduler(schedName) now creates the scheduler when the name asked for is its own. There are no API or schema changes, so it is a drop-in upgrade from 3.19.0.
DailyTimeIntervalTrigger no longer spins on DST transition days — GetFireTimeAfter could return a time at or before the one it was given, which makes QuartzSchedulerThread fire the trigger, compute the same next fire time, and fire again — pinning a CPU core and flooding the log. Two independent causes, both on a spring-forward day: the DST correction added for #1114 was applied to every interval size and in either direction (so every interval of an hour or less was affected, in every DST time zone), and the daily rollover to StartTimeOfDay reused whatever UTC offset the previous fire time carried (so in time zones that move the clock at midnight, such as Chile, StartTimeOfDay 00:00 resolved to an instant before the transition — the same instant that was passed in). Verified across 3024 combinations of 12 time zones, both transitions, 21 intervals and 6 start times: 468 combinations produced non-advancing fire times before, none do now. (#3190, fixes #332)
StdSchedulerFactory.GetScheduler(schedName) creates its own scheduler — asking a factory for the scheduler it is configured to produce returned null until somebody had called GetScheduler() first. It now creates it. Any other name stays a pure lookup, so probing for a scheduler somebody else owns still has no side effects, and the name comparison is case-insensitive to match how SchedulerRepository indexes names. The DI factory has behaved this way since #2845; this brings the property-configured factory in line. (#3188, reported in #2786, originally proposed in #360)Full Changelog: v3.19.0...v3.19.1
Quartz.NET 3.19.0 is a feature release: it adds node affinity for clustered scheduling, a fluent cron-expression builder, and richer L / LW day-of-mon
Quartz.NET 3.19.0 is a feature release: it adds node affinity for clustered scheduling, a fluent cron-expression builder, and richer L/LW day-of-month expressions, plus clock-jump resilience and a modernized build and publishing pipeline. The public API is unchanged (all additions are additive), so it is a drop-in upgrade — with two things to note: the new node-affinity columns are an optional schema migration (the feature degrades gracefully without them), and a handful of previously-broken L/LW/W cron expressions now fire correctly (see below).
TriggerBuilder.WithPreferredNode(...); the node is preferred for acquisition but the trigger is still stolen on failover so it is never stranded if that node goes down. Adds optional PREFERRED_NODE / PREFERRED_NODE_AUTO columns for ADO.NET job stores (database/schema_30_add_preferred_node.sql); when the columns are absent the scheduler logs a warning and behaves exactly as before. (#3013, #3144)CronExpressionBuilder — compose cron expressions programmatically, one field at a time, instead of hand-writing the string — handy when a schedule is assembled from user input such as a scheduling UI. (#3139)L and LW combinable with other day-of-month values — the day-of-month field now accepts expressions such as 1,15,L and the new LW-n / L-nW grammar. This also corrects several previously-buggy edge cases: 29W/31W no longer silently skip short months, L-30W no longer throws mid-schedule, and 1,15W now applies W to each day rather than only the first. These corrections change the fire times of a few expressions that were previously broken — review any stored L/LW/W day-of-month expressions. (#2759)Note for CronScheduleBuilder users: AtHourAndMinuteOnGivenDaysOfWeek / WeeklyOnDayAndHourAndMinute now emit textual day-of-week names (e.g. MON,WED rather than 2,4). The schedules are identical, but the generated CRON_EXPRESSION string differs — relevant only if you compare stored cron strings byte-for-byte.
Full Changelog: v3.18.2...v3.19.0
It is a drop-in upgrade from earlier 3.18.x releases — no breaking changes and no database schema migrations.
Quartz.NET 3.18.2 is a maintenance release that stabilizes the dashboard introduced in 3.18.0, fixes several scheduling correctness bugs, and speeds up cron next-fire-time computation. It is a drop-in upgrade from earlier 3.18.x releases — no breaking changes and no database schema migrations.
AcquireNextTrigger and block acquisition of every other trigger. The faulting trigger is now isolated so the rest keep firing. (#3108)Quartz configuration section were not loaded for named schedulers. (#3113)DashboardPath work under a fail-closed FallbackPolicy, and the Blazor circuit is allowed anonymous access under the same policy (#3098, #3120); trigger and calendar JSON deserialization is fixed (#3102); and JobDataMap and SimpleSchedule trigger details now display correctly (#3132).FORCE INDEX fix — schema-qualified table prefixes produced a malformed FORCE INDEX hint. (#3086)DateTimeOffset churn in the hot loop. (#3126, #3129)AddQuartzServer (#3112), and new public helpers make plugin configuration extensible (#3104).Full Changelog: v3.18.1...v3.18.2
Vulnerable transitive dependency bumps — addresses moderate advisories that were blocking restore on the 3.x CI pipeline.
Quartz.NET 3.18.1 is a maintenance release that addresses a timezone-related bug, security advisories on transitive dependencies, and an issue that prevented the new Redis distributed-lock package from publishing to nuget.org.
GetTimeBefore for positive-offset timezones — CronExpression.GetTimeBefore could throw ArgumentOutOfRangeException for cron expressions evaluated against timezones with positive UTC offsets (e.g. Europe/Helsinki, Asia/Tokyo). Backported from main.Quartz.Extensions.Redis — the original Quartz.Redis package id from 3.18.0 collided with an unrelated v1.0.0 already on nuget.org and could not be published. The 3.18.0 release was shipped with IsPackable=false as a workaround; 3.18.1 picks the umbrella id Quartz.Extensions.Redis (matching Quartz.Extensions.DependencyInjection / Quartz.Extensions.Hosting) and re-enables publication. There are no consumers to migrate — the original id was never on nuget.org.Full Changelog: v3.18.0...v3.18.1
Quartz.NET 3.18.0 is a feature release: RRULE recurrence scheduling, per-node execution limits, multiple named schedulers in DI, JSON configuration, a
Quartz.NET 3.18.0 is a feature release: RRULE recurrence scheduling, per-node execution limits, multiple named schedulers in DI, JSON configuration, a Redis lock handler, and job store tracing. All additions are additive, so it is a drop-in upgrade — with two caveats: execution groups add an optional column for ADO.NET job stores, and the Redis package only reached nuget.org in 3.18.1 (see below).
WithRecurrenceSchedule("FREQ=MONTHLY;BYDAY=2MO"). A self-contained RRULE engine covers all frequencies, the BY* rules, COUNT, UNTIL, INTERVAL and WKST; no new dependencies and no schema change — it reuses SIMPROP_TRIGGERS. (#2990, closes #1259)UseExecutionLimits(...), the quartz.executionLimit.<group> keys, or scheduler.SetExecutionLimits(...) at runtime. Adds an optional EXECUTION_GROUP column for ADO.NET job stores, with graceful fallback when it is absent. (#3004, closes #1175, #830)AddQuartz("Name", ...) registers independent schedulers with their own options, jobs, triggers, listeners and calendars; AddQuartzHostedService() manages all of them. (#3000, closes #2109)Quartz section in appsettings.json rather than flat property keys, including declarative jobs and triggers for all four trigger types and a Schedulers section for named schedulers. A standalone JsonSchedulingDataProcessorPlugin reads quartz_jobs.json with hot reload, mirroring the XML plugin. (#3012, #3015, #3017, closes #1755)RedisSemaphore uses Redis SET NX PX locks instead of database row locks, removing row-lock contention in clusters while job and trigger data stay in the relational store. The package ships as Quartz.Extensions.Redis from 3.18.1 onward; in 3.18.0 it was built with IsPackable=false because the original Quartz.Redis id was already taken on nuget.org. (#2999, #3053, closes #1625)IJobStore methods are wrapped in System.Diagnostics.Activity spans, so database calls nest under named operations such as Quartz.JobStore.AcquireNextTriggers instead of appearing as orphaned roots. No overhead when tracing is off. (#3001, closes #2721)UpdateTriggerDetails — change a trigger's description, priority, job data map, calendar name or misfire instruction without resetting its fire times, state or misfire context. (#2988, closes #844)AddQuartz overloads with IServiceProvider — resolve services while configuring Quartz, for connection strings, feature flags and the like. (#3007, closes #1617)UPDATE replaces the full StoreTrigger path, cutting per-trigger round-trips from 7–12 to 1–2 and caching calendar lookups across the batch; a batch of 20 cron triggers drops from ~150 queries to ~20. (#2993, closes #758)SchedulerRepository now supports instance-aware lookup so remote proxies to several nodes of one cluster can coexist (#2991, fixes #388); IsJobGroupPaused/IsTriggerGroupPaused are implemented in the ADO.NET store instead of throwing (#3030); and the Dashboard works over plain HTTP (#3032) and no longer depends on a static ServiceProvider (#3035).Full Changelog: v3.17.1...v3.18.0
Quartz.NET 3.17.1 adds two features that landed just after 3.17.0 — the Jenkins-style H cron token and structured-logging history plugins — along with
Quartz.NET 3.17.1 adds two features that landed just after 3.17.0 — the Jenkins-style H cron token and structured-logging history plugins — along with a fix for triggers that could get permanently stuck in the ACQUIRED state. It is a drop-in upgrade with no API or schema changes.
H (hash) token in cron expressions — borrowed from Jenkins, H resolves to a value derived deterministically from the trigger's identity, so many triggers sharing one schedule spread out instead of stampeding. Supported forms are H, H(min-max), H/step and H(min-max)/step; TriggerBuilder seeds the hash from the trigger name and group, or you can supply the seed yourself. 0 H * * * ? runs at a stable but spread-out minute each hour; 0 H/15 * * * ? runs every 15 minutes from a hashed offset. See the cron trigger documentation. (#2977, closes #1330)StructuredLoggingJobHistoryPlugin and StructuredLoggingTriggerHistoryPlugin use named message-template parameters ({JobName}, {TriggerGroup}) instead of the index-based {0}/{1} placeholders of the existing plugins, so output is queryable in Serilog or NLog sinks and the template cache stops growing without bound. The templates are configurable through the usual property configuration. (#2981)StoreCalendar no longer overrides paused triggers — storing a calendar with updateTriggers: true reset paused triggers to normal. (#2968, fixes #2053)/_blazor endpoint (#2975).Full Changelog: v3.17.0...v3.17.1
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Nothing published for this version
Your coding agent can read these notes before it upgrades. Set up the MCP server →