Download Latest Version Quartz.NET-4.4.0.zip (48.3 MB) Google Add to Preferred Sources
Home / v4.3.0
Name Modified Size InfoDownloads / 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 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.

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 says ExecutionGroup = $"tenant:{input.TenantId}". Concurrency keys, which Oban, Sidekiq, JobRunr and BullMQ sell in their paid tiers. (#3876)
  • Idempotent enqueue and debounce. OneOffJobOptions.OnConflict is Throw (as before), Replace, Keep (enqueue unless it is already pending) or KeepEarlier (whichever fires first stays). New IScheduler.ScheduleTrigger(trigger, onConflict) / IJobStore.StoreTrigger(trigger, onConflict) report whether the trigger was created, replaced or kept; the HTTP API takes onConflict. (#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.SqlServer and Quartz.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 the application's own, instead of ProvisionSchema() and hand-run scripts. One call on the store: store.UseWeaselForPostgres(), UseWeaselForSqlServer() or UseWeaselForSqlite(). (#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, and CronExpressionBuilder.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. New 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: RepeatCount (forever by default), the misfire instruction, priority, execution group and ConfigurationKey. New QZ0005 refuses an interval that is not a positive TimeSpan at 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.MaxBatchSize defaults to 0, meaning automatic (see above); an explicit value wins as before, and quartz.scheduler.batchTriggerAcquisitionMaxCount still 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 after Standby() has returned. Two schedule calls in quick succession no longer hide the earlier, more urgent trigger, which could run up to IdleWaitTime late or misfire, and could fire during standby after Standby() + 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 FakeTimeProvider nobody 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. New DbMetadata.DbLargeTextTypeName / ConfigureLargeTextParameter, and a UseOracle overload for the factory path. (#3874)
  • The memory-optimized and pre-2016 SQL Server table scripts gain 4.2's RETRY_ATTEMPT/RETRY_SCHEDULED history columns, which they had been missing. A schema created from either under 4.2.0 needs the guarded ALTER now 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, or MaxBatchSize = 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 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. 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 through LogProvider, so under AddQuartz without SetLogProvider an 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 with UseJobFactory<MyFactory>() building jobs from another container, ValidateOnBuild failed Build() 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)
  • HttpScheduler escapes 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 an ArgumentException naming it, instead of reaching another route. A decorator over HttpScheduler backfills in one request too. (#3917)
  • HttpScheduler sends 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.Quartz needs 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. Pin Quartz to [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.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 a dependency; the package count goes from 14 to 18.
  • The repository stays on Verify 32.x: 33 adds a build-time licence check. (#3885)
Source: README.md, updated 2026-09-28