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

Update dependency Quartz.AspNetCore to v4#1373
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.04.0.1 age confidence

Release Notes

quartznet/quartznet (Quartz.AspNetCore)

v4.0.1

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

What changed

  • A consumer one servicing patch behind restores 4.0.1 — 4.0.0's package manifest floored every Microsoft.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)
  • A grouped dependency update can reach 4.x — the four packages 4.0 folded into 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)
    • Behavior change worth noting: 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.
  • The migration guide has a section for F# — an F# implementer sees four errors no C# project does (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)

Upgrading

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: quartznet/quartznet@v4.0.0...v4.0.1

v4.0.0

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:

  • 4.x migration guide — start at Start here; every breaking change with before and after, an ordered runbook for a running deployment, and an appendix that indexes every removed name
  • Database schema changes — what to run, per version and per database
  • Before you go live — the production checklist
  • Quick start — if you are starting something new, none of the above applies
dotnet add package Quartz

Highlights

  • net10.0 only — no netstandard2.0 build, no Full Framework, no .config support.
  • The container builds the scheduler. Dependency injection and hosting are part of the core 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.
  • Job store listings became queriesQueryJobs / 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.
  • A cluster can be read from outside. 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.
  • Recurrence triggers (RFC 5545 RRULE), an HTTP API with an 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.
  • Joining a transaction the application owns — the ADO job store can enlist in a transaction you started, so saving your data and scheduling the job that acts on it commit together or not at all.
  • Cron says what it means. A wildcard day field no longer swallows the other day field's restriction (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.
  • Daylight saving fire times changed. Interval cron expressions fire through both halves of a repeated fall-back hour; 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.
  • Safe by default. 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.
  • Observability that a backend can subscribe to by name. 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.
  • Trimming and native AOT are something CI runs. 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).
  • Faster per firing than 3.20 on both stores — 1.5× faster and 2.4× less allocation on PostgreSQL, faster and 21 % less allocation on RAMJobStore (numbers below).
  • Every public member is documented and the compiler holds it there; every collaborator is handed a context object; any member added to a public interface in 4.x arrives as a default interface member. The seams are registered in Extending Quartz.
  • A much smaller public surface — the scheduler core, the ADO SQL and 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.

Breaking changes, the ten to know before the guide

  1. net10.0 only.
  2. 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.
  3. StdSchedulerFactory, DirectSchedulerFactory and quartz.config are gone; AddQuartz(…) or QuartzSchedulerBuilder.Create(q => …) build a scheduler.
  4. TaskValueTask on nearly every member, and a CancellationToken on every asynchronous one.
  5. 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.
  6. Every listener callback is told which scheduler is calling; the three *Support base classes are gone (every member has a default body); a listener with a 3.x signature is refused at registration.
  7. Misfire instructions are per-family enums; TimeOnly and DateOnly replace TimeOfDay; TimeProvider replaces SystemTime; the semaphores are lock handlers.
  8. The cron rules above — audit stored expressions with the guide's query before upgrading; a newly refused form fails at load, loudly. The reverse is quiet: an expression from another six-field dialect that 3.x refused (no ? 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.
  9. The schema: columns that were optional on 3.x are required, the acquisition index is reshaped, one index is dropped, one table is added. The upgrade script runs while 3.x nodes are up; the index script runs once the last one is gone.
  10. The trigger's end time is the last instant at which it may fire, uniformly, and the daylight-saving fire times above.

Everything else — every renamed member, every sealed type, every removed constant — is in the guide, with the name you would have typed.

Fixes worth knowing about

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.
  • Recovered triggers lost their priority, so recovered work was acquired last.
  • The misfire threshold instant was a misfire on one store and not on the other, and the ADO store disagreed with itself; one rule now, and the acquisition predicate is its exact complement (#​3462).
  • A trigger kept the wall clock whatever TimeProvider the scheduler was given (#​3456).
  • Calendars were the wrong day where midnight moves, and DailyCalendar could crawl for minutes to answer months late (#​3457, #​3466).
  • A job listener that threw synchronously wedged the firing, and its [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).
  • Firebird's serialization failures were never retried (#​3454).
  • A trigger stored as a plain object graph lost its time zone under Newtonsoft (#​3494), and a job data value the JSON format could not read back went into the database anyway (#​3495) — both serializers now refuse at write time.
  • A job whose trigger was re-applied by the XML processor fired twice at startup (#​3554).
  • A sub-millisecond repeat interval was stored as zero and wedged the trigger in 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).
  • A paused job group did not bind jobs added to it afterwards on the persistent store; a blank calendar name silently killed a trigger (#​3294); MySQLDelegate forced an index the misfire sweep could not use.
  • A process that cannot load the job classes can still edit their schedules. Rescheduling and updating a trigger on the persistent store resolved the job class and failed without it; the two attribute flags now come from the row, so an administration node needs no ITypeLoader of its own (#​3705). Stopping the host twice at once — which host.StopAsync() during RunAsync() does — no longer throws out of RunAsync (#​3701).

Numbers

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.

Known limitations

Each is documented on the page that owns it; this is the list, not the explanation.

  1. No external production user is known at the time of this release; the validation is ours, and it is described in the pre-release notes below.
  2. An authorized caller of the HTTP API or the dashboard is trusted with code execution on the host (HTTP API, SECURITY.md).
  3. SendMailJob reads SMTP credentials out of job data when nothing is registered (Quartz.Jobs).
  4. No rate limiting on any Quartz surface, by design.
  5. On shutdown, in-flight jobs are neither waited for nor cancelled by default, and the scheduler says so (hosted services).
  6. MaxConcurrency defaults to 10 on the shared thread pool (configuration reference).
  7. HttpScheduler.Status and SchedulerInstanceId block on an HTTP round trip (HTTP client).
  8. OpenTelemetry.Instrumentation.Quartz produces nothing on 4.x, and 4.x warns at start if it is present (OpenTelemetry).
  9. SQLite cannot join an ambient TransactionScope (job stores).
  10. Reflective string-named paths remain under trimming; each has a documented alternative (#​3341).
  11. Least exercised: Quartz.Extensions.Redis (one unit and one container fixture); Quartz.Aspire under an AppHost was run by hand, not in CI.
  12. The strong-name key pair is committed and public; nuget.org's repository signature and trusted publishing are what stand behind a package.

Upgrading a running deployment

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.

If you ran a 4.0 pre-release

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.

Thanks

Full changes

v3.21.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 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.0

What changed

  • ResumeAll 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)
    • Behavior change worth noting: a group paused while empty no longer survives ResumeAll.
  • A job store closes its lock handler when it shuts downRedisSemaphore 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)
    • Behavior change worth noting: a custom ISemaphore that implements either interface is now disposed at shutdown.
  • A configured wait longer than the platform's timer ceiling is refused where it is setTask.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)
    • Behavior change worth noting: a value past the ceiling now fails at configuration rather than later.

Public API — additive only

  • Quartz.Extensions.Redis: RedisSemaphore implements IAsyncDisposable and IDisposable.

Upgrading

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: quartznet/quartznet@v3.20.1...v3.21.0

v3.20.1

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.1
What changed

Landed on the branch since 3.20.0:

  • A DailyTimeIntervalTrigger stored through the default Newtonsoft path reads back againTimeOfDay 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)
  • A daily time interval trigger never fires before it startsStartTimeUtc 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)
  • A trigger with nothing left to fire is finished however its last firing ended — a firing abandoned by a failing job listener, a veto or a shutdown left a one-shot trigger waiting for ever in RAMJobStore and as a permanent COMPLETE row in the ADO store. Both stores finish it now. (0af9431d3e, #​3507)
  • The in-memory store applies the misfire policy of a trigger it unblocks — a trigger blocked behind a [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)
  • Pausing a trigger no longer throws its error awayRAMJobStore 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)
    • Behavior change worth noting: 3.20.0's notes listed this among the store-parity alignments left off 3.x; it is on 3.x now.
  • Rescheduling recomputes a fire time its new start time left behindRescheduleJob 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)
  • A trigger loaded beside its job by the XML processor is scheduled once, not twice — every such trigger was scheduled and then immediately rescheduled with overwrite-existing-data on, and a repeating trigger that starts now fired twice milliseconds apart. (f25080cef6, #​3554)
  • Both serializers read a string dictionary written by the other — a 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)
  • Schema validation checks 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)
  • The 3.20 index-alignment scripts name the 4.0 index script that supersedes them (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:

  • A job listener that throws before it returns no longer wedges the firing — a synchronous throw from 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)
  • MySQL's misfire sweep reads the index that has the shape it needs — both misfire statements and the count every misfire pass starts with were forced onto 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)
  • A process that cannot load the job classes can edit their schedulesRescheduleJob 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)
  • A transaction the database rolled back is retried whatever the driver calls it — SQLSTATE class 40 (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)
    • Behavior change worth noting: those failures are retried where they were treated as permanent.
  • A persistent store refuses a repeat interval it cannot hold — a 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)
    • Behavior change worth noting: storing such a trigger throws where it used to succeed and leave the trigger stuck; a trigger already stored with a zero interval is unaffected by this release.
  • System.Text.Json refuses a job-data value it cannot read back — a 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)
    • Behavior change worth noting: such a value is a 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.
  • A daily time interval trigger stops at its end time — an 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)
    • Behavior change worth noting: a trigger whose end time fell mid-window fires fewer times than on 3.20.
  • 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)
  • A connection that cannot join the ambient transaction is refusedEnlistConnection 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)
  • The misfire threshold instant itself is late, on every store — a trigger due at exactly 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)
    • Behavior change worth noting: a trigger due at exactly that instant is swept as a misfire where the periodic sweep used to pass over it, so its misfire instruction now applies to it.
  • A job-data number written with a decimal comma is unreadable, not a hundredfold of itself"3,14" read as 314 from the floating-point accessors while GetInt threw. (port of the beta.1 fix)
    • Behavior change worth noting: GetDouble and GetFloat throw a FormatException for such a string where they used to answer a number a hundred times too large.
  • A group matcher containing [ 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)
    • Behavior change worth noting: a matcher whose value contains [ 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)
Public API

Unchanged. No signature was added, altered or removed, and the PublicApiTest baselines did not move.

Upgrading

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.

About the 4.0 line

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: quartznet/quartznet@v3.20.0...v3.20.1

v3.20.0

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:

  1. The database scripts moved. Everything under 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.
  2. The index migration is optional and performance-only (database/migrations/3.20/). Nothing needs it to run, but PostgreSQL users should read that bullet.
  3. A few fixes change what fires when. Each is marked Behavior change worth noting below.
  4. One rolling-upgrade caveat, for a narrow case involving the Newtonsoft serializer — see the note before What's Changed.

Highlights

  • The job store can take part in the application's transaction — scheduling and your own database work can now commit or roll back together, without subclassing the job store. Turn it on with 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)
    • Known limits, all documented on the pull request: the job store's locks are held until the caller's transaction completes, so a long application transaction blocks trigger acquisition, the misfire handler and cluster check-in; there is no savepoint, so a half-failed operation leaves its statements in the caller's transaction; a connection opened before the ambient scope it is enlisted under is not really in that scope and ADO.NET offers no portable way to detect it; and txIsolationLevelSerializable applies only to the job store's own connections.
  • Job instantiation failures carry the trigger, the job and the fire instance id — when 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)
    • Behavior change worth noting: in the 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.
  • Daylight-saving resolution is centralised, and two latent defects are gone — the trigger-wide DST policy existed in one explicit place and was otherwise implicit, working only by the accident of TimeZoneInfo.GetUtcOffset returning the pre-gap offset for positive-delta zones. It is now two internal TimeZoneUtil helpers — ResolveLocal (ambiguous → the first/daylight

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 from cfb06c6 to 82c6548 Compare September 9, 2026 03:11
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