Skip to content

Update dependency Quartz.AspNetCore to v4 - #1373

Open
renovate[bot] wants to merge 1 commit into
dev8from
renovate/major-quartznet-monorepo
Open

renovate[bot] wants to merge 1 commit into
dev8from
renovate/major-quartznet-monorepo

Conversation

@renovate

@renovate renovate Bot commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

ℹ️ Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Change Age Confidence
Quartz.AspNetCore (source) 3.14.0 → 4.3.0 age confidence

Release Notes

quartznet/quartznet (Quartz.AspNetCore)

v4.3.0

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.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 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)

v4.2.4

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.

Fixed
  • A database error on one trigger of a fire batch no longer commits half of it. A non-transient error while firing one trigger was recorded as that trigger's failure, and the batch committed 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. New Warning event 3049 names the trigger. (#​3931, #​3942)

The same fix ships for 3.x in 3.22.3.

Full Changelog: quartznet/quartznet@v4.2.3...v4.2.4

v4.2.3

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.

Fixed
  • 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 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 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. An application that had checked a calendar or an expression against other decades could leave the current window on the slow path for good, about 520 ns instead of about 37 ns a lookup. One window that failed verification also switched the whole zone off. Each window now gets its own table, built once. (#​3918, #​3932)

The acquisition fix ships for 3.x in 3.22.2. 3.x has no cron fast path.

Full Changelog: quartznet/quartznet@v4.2.2...v4.2.3

v4.2.2

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.

Fixed
  • A trigger no longer fires after 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)
  • A trigger scheduled just after another is no longer passed over, and one held in standby is let go. When two schedule calls landed before the loop read the first, the later one's time overwrote the earlier, more urgent one. That trigger then ran up to 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)
  • 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, the handler retried its lock statement inside the dead transaction. That is error 41302 with memory-optimized 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)
  • A shutdown that does not wait for its jobs gives them their settle window in wall time, whatever clock the scheduler keeps. On a frozen FakeTimeProvider it used to wait forever. (#​3907)

The same scheduling and lock-handler fixes ship for 3.x in 3.22.1.

Full Changelog: quartznet/quartznet@v4.2.1...v4.2.2

v4.2.1

Two fixes, both additive; no public API or schema-script change for anyone on the default SQL Server script or another database.

Fixed
  • 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: quartznet/quartznet@v4.2.0...v4.2.1

v4.2.0

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.0
Highlights
  • A cron or timeout literal that cannot parse is a build error. Quartz.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)
  • A job declares itself on its class, and the compiler writes the registration. [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)
  • A trigger can wait, in the store, for another trigger's outcome. 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)
  • A retry policy that gives up says so. 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)
  • A cron expression's next occurrence is eleven times faster and asks the time zone nothing on the steady-state path. 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)
  • Quartz is measured against the schedulers it is compared to, and the numbers are published. 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)
  • Execution history can live in the database, and the store keeps it trimmed. 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)
  • The dashboard can be pointed at a database and show every scheduler in it. 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)
  • The documentation compares, and says where Quartz loses. A comparison page puts Quartz.NET beside Hangfire 1.8.25, TickerQ 10.4.0, Wolverine 6.35 and Coravel 6.0, one sourced table per concern, with a section collecting the places Quartz is the more expensive answer; two guides map Hangfire's and TickerQ's APIs to ours and name the differences that bite (six cron fields and a different default time zone, [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)
  • The health check can say a scheduler has stopped firing. 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)
Public API — additive only
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

❗ Important

✂ PR body was truncated to here.


Configuration

📅 Schedule: (in timezone Asia/Shanghai)

  • Branch creation
    • "before 1am,before 5am,before 9am"
  • Automerge
    • At any time (no schedule defined)

🚦 Automerge: Enabled.

♻ Rebasing: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about this update again.


  • If you want to rebase/retry this PR, check this box

This PR was generated by Mend Renovate. View the repository job log.

@renovate
renovate Bot force-pushed the renovate/major-quartznet-monorepo branch 2 times, most recently from 82c6548 to 125ee2e Compare September 13, 2026 13:28
@renovate
renovate Bot force-pushed the renovate/major-quartznet-monorepo branch 2 times, most recently from f7f4f7c to 7c5ccc0 Compare September 25, 2026 19:17
@renovate
renovate Bot force-pushed the renovate/major-quartznet-monorepo branch 3 times, most recently from bb17b05 to 2cff436 Compare September 28, 2026 23:49
@renovate
renovate Bot force-pushed the renovate/major-quartznet-monorepo branch from 2cff436 to 9d976b1 Compare September 29, 2026 11:56

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants