| Name | Modified | Size | Downloads / Week |
|---|---|---|---|
| Parent folder | |||
| Quartz.NET-4.3.0.zip | 2026-09-29 | 37.4 MB | |
| README.md | 2026-09-28 | 23.5 kB | |
| v4.3.0 source code.tar.gz | 2026-09-28 | 8.2 MB | |
| v4.3.0 source code.zip | 2026-09-28 | 10.3 MB | |
| Totals: 4 Items | 55.9 MB | 1 | |
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.
:::shell
dotnet add package Quartz --version 4.3.0
Highlights
Faster where it runs
- A 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 onRAMJobStore, a little faster too. Every other job keeps its scope exactly as before. (#3866) RAMJobStorecomputes 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.
MaxBatchSizenow 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 = 1restores 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.
ScheduleJobwith 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.
A job can be a lambda
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)
See what a running job is doing
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)
Say what happens when the last firing is still running
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)
A pause says why
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)
The dashboard is a control panel
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)
Steer who runs, and how often the same thing is enqueued
- Per-tenant limits.
limits.ForGroupsWithPrefix("tenant:", 2, ExecutionLimitScope.Cluster)gives every group under the prefix its own allowance, cluster-wide;WithExecutionGroup("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 BullMQ sell in their paid tiers. (#3876) - Idempotent enqueue and debounce.
OneOffJobOptions.OnConflictisThrow(as before),Replace,Keep(enqueue unless it is already pending) orKeepEarlier(whichever fires first stays). NewIScheduler.ScheduleTrigger(trigger, onConflict)/IJobStore.StoreTrigger(trigger, onConflict)report whether the trigger was created, replaced or kept; the HTTP API takesonConflict. (#3877)
Backfill a range you missed
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's tables under Weasel
- For applications on Marten or Wolverine: new packages
Quartz.Weasel.PostgreSQL,Quartz.Weasel.SqlServerandQuartz.Weasel.SQLiteput the ADO.NET job store's schema under Weasel.db-apply,db-assert,db-patch,resources setupand AutoCreate at startup then create and migrate Quartz's tables beside the application's own, instead ofProvisionSchema()and hand-run scripts. One call on the store:store.UseWeaselForPostgres(),UseWeaselForSqlServer()orUseWeaselForSqlite(). (#3941, [#3948]) - The model is generated from the same source as the store's own scripts, so a database created by
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. Tables are add-only: columns, indexes and keys you added are kept. Every apply takes a lock, so nodes starting together take turns. On PostgreSQL the tables can instead join a Marten store as a feature schema. (#3941, [#3948]) - MySQL and Oracle follow once the fixes we sent to Weasel are released (JasperFx/weasel#639–#649). Firebird has no Weasel provider. Weasel schema management
- Thanks to Jaedyn, whose Weasel.Quartz (github.com) was the first Weasel integration for Quartz.NET.
Test with a clock you control
Advancing 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)
Declared where it is used
t.WithCronSchedule(cron => cron.AtTime(new TimeOnly(3, 0)).OnWeekdays())configures the expression inline, andCronExpressionBuilder.Every(TimeSpan.FromMinutes(10))is a clock-anchored interval (0 0/10 * ? * *). (#3868)q.AddJobLogScope()puts the job, trigger and fire instance on every log line a firing writes. Opt-in: 272 bytes a firing when on, nothing when off. (#3870)[CronTrigger("0 0 2 * * ?", ConfigurationKey = "Jobs:Cleanup:Cron")]reads the schedule from configuration, falling back to the literal the compiler checked; a bad configured value fails at startup. NewQZ1005;QZ1004is now Info, becauseAddDeclaredJobsFrom<Assembly>()is always emitted besideAddDeclaredJobs(). (#3871)[SimpleTrigger("00:10:00")]declares an interval job the way[CronTrigger]declares a cron one:RepeatCount(forever by default), the misfire instruction, priority, execution group andConfigurationKey. NewQZ0005refuses an interval that is not a positiveTimeSpanat compile time. (#3924)
Database schema
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)
Behaviour changes
QuartzSchedulerOptions.MaxBatchSizedefaults to0, meaning automatic (see above); an explicit value wins as before, andquartz.scheduler.batchTriggerAcquisitionMaxCountstill maps to it. (#3862)
Public API
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.
Also
- A row-lock handler no longer acts on a transaction SQL Server has ended. When SQL Server aborted a transaction
contending for the lock row (error 41302 on memory-optimized tables, or deadlock victim 1205 with the default
handler), the handler retried its lock statement inside the dead transaction. SqlClient ran it 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. The memory-optimized smoke test now runs on the SQL Server CI leg. New Information event 3048 names a lock handler that was supplied rather than built. Also in 4.2.2 and 3.22.1. (#3903) - A
Standby()that lands while the scheduler is between rounds is no longer slept through, so no trigger fires afterStandby()has returned. Two schedule calls in quick succession no longer hide the earlier, more urgent trigger, which could run up toIdleWaitTimelate or misfire, and could fire during standby afterStandby()+ScheduleJob(). Also in 4.2.2 and 3.22.1. (#3901) - A shutdown that does not wait for its jobs gives them their settle window in wall time on any clock. (#3901)
- The competitor benchmark's database census reads PostgreSQL's commit counters after they are published; the earlier TickerQ figure (0.21 commits an execution) was read too soon. Its README now says what each engine really costs the database. (#3861)
- A persistent store's shutdown waits for its misfire and check-in loops at most a second of wall time. It used to
measure that second on the scheduler's own clock, so on a
FakeTimeProvidernobody advanced a shutdown could wait forever. Also in 4.2.1. (#3892) - The docs site loads no analytics script: its Universal Analytics tag had recorded nothing since 2023. (#3688)
- A string longer than 4,000 characters is bound as
nvarchar(max)on SQL Server rather than truncated to the parameter's width, and a captured log is bound as a CLOB on Oracle. NewDbMetadata.DbLargeTextTypeName/ConfigureLargeTextParameter, and aUseOracleoverload for the factory path. (#3874) - The memory-optimized and pre-2016 SQL Server table scripts gain 4.2's
RETRY_ATTEMPT/RETRY_SCHEDULEDhistory columns, which they had been missing. A schema created from either under 4.2.0 needs the guardedALTERnow on the schema-changes page. (#3874, [#3914]) - A clustered node no longer idles behind a
[DisallowConcurrentExecution]job running on another node. When two nodes reserved triggers of the same such job, the one that lost released its trigger back to waiting, though the job was still running. With one trigger acquired at a time (4.2's default, orMaxBatchSize = 1), that trigger then sat at the head of the node's queue: skipped on every pass, it hid the triggers due behind it, and the node slept its whole idle wait. 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 the new mixed-version cluster test. (#3926) - A database error on one trigger of a fire batch no longer commits half of it. A non-transient error
firing one trigger was recorded as that trigger's failure and the batch committed. On SQL Server and MySQL that
kept the failed trigger's partial writes, which could leave its
[DisallowConcurrentExecution]job's other triggersBLOCKEDfor 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. 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) - An execution group at its limit no longer hides the triggers due behind it from a node acquiring one
trigger at a time. The driver delegate now returns such a row flagged
(
TriggerAcquireResult.ExecutionGroupAtLimit) instead of dropping it, so acquisition reads past it as it does past its own skips. A delegate that still drops the row behaves as before. (#3928) - The cron fast path answers in every year a trigger can reach, whatever the process asked about first. Its offset tables were capped at four 8-year windows per time zone, so an application that had checked a calendar or an expression against other decades could leave the current one on the slow path for good (~520 ns instead of ~37 ns a lookup); and one window that failed verification switched the whole zone off. Each window now gets its own table, built once. (#3918)
- A text the store keeps short (pause reason, progress message, a history entry's error message) is cut to what its column holds in bytes on Oracle and on a Firebird database without a character set, and never between the halves of a surrogate pair. A history error message used to be cut through the middle of a character on every database. The database page says how to make Firebird and Oracle count characters. (#3905)
AddJobTimeout's events (1090 timed out, 1091, 1092) are logged through the container's logging. They used to go throughLogProvider, so underAddQuartzwithoutSetLogProvideran overrun logged nothing. (#3916)- A scheduler with a job factory of its own registers none of its job types in the container. 4.0–4.2
registered every
AddJob<T>/ScheduleJob<T>type, so withUseJobFactory<MyFactory>()building jobs from another container,ValidateOnBuildfailedBuild()on dependencies only that factory supplies. The default factory, and one derived from it, register and validate as before; an application's own registration is kept. Code that resolved a job type from the container behind a custom factory registers it itself. (#3930) HttpSchedulerescapes every value it puts in a path or a matcher query, so a name with?,#,%,&or a space reaches the right job, trigger or calendar. A name containing/, or one that is.or.., cannot survive a URL and is refused with anArgumentExceptionnaming it, instead of reaching another route. A decorator overHttpSchedulerbackfills in one request too. (#3917)HttpSchedulersends every call through one route table shared with the server, so a client and the HTTP API cannot spell a path differently. An error answer without problem details now carries the standard reason phrase for its status. (#3770)- A job flagged
DisallowConcurrentExecution()by its builder, with no attribute on its type, took two slots of one acquisition batch on a persistent store; it never ran twice at once, but the second slot was wasted. Acquisition now reads the stored flag (TriggerAcquireResult.ConcurrentExecutionDisallowed), and a driver delegate whose own statement does not select it falls back to the type as before. (#3867) - The docs say
MassTransit.Quartzneeds Quartz.NET 3.x: its range has no upper bound, so NuGet resolves 4.x and the application fails to build (CS0121) or to start. PinQuartzto[3.22.0, 4.0). (#3872) - The docs no longer say a batch needs a fire-ahead window: at a window of zero, a batch takes every trigger already due. (#3872)
Dependencies
- 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 transitively, so a test-only package can move without moving a floor a consumer inherits. (#3855)
- The four new packages depend on
Weasel.Core,Weasel.Postgresql,Weasel.SqlServerandWeasel.Sqlite[9.35.1, 10.0.0), and through them on JasperFx 2.74 and the database driver. No existing package gains a dependency; the package count goes from 14 to 18. - The repository stays on
Verify32.x: 33 adds a build-time licence check. (#3885)