Skip to content

Update dependency Quartz.AspNetCore to v4 - #1869

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

Update dependency Quartz.AspNetCore to v4#1869
renovate[bot] wants to merge 1 commit into
mainfrom
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.15.14.0.0 age confidence

Release Notes

quartznet/quartznet (Quartz.AspNetCore)

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.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 occurrence; nonexistent → paired with the pre-gap offset, found by scanning backwards a minute at a time so 30-minute deltas and negative-delta zone models work) and WalkToGapEnd — with all five call-site families migrated onto them. Two defects fell out: a negative-daylight-delta gap hazard, where a zone modelled with a negative delta (Europe/Dublin on TZif data, so Linux and macOS) produced an instant before the gap — a scheduler hot-loop hazard the ambiguity demotion does not cover; and a sub-second demotion defect, where CronExpression.GetTimeAfter compared its whole-second candidate against the untruncated after-time, so a sub-second after-time inside the repeated fall-back hour demoted a valid fire a whole hour forward. The migrations are behaviour-neutral for every positive-delta zone, guarded by a minute-by-minute differential test across ±3 hours of all eight test zones' transitions. (#​3198, building on #​3195)
    • Behavior change worth noting: the sub-second fix makes GetTimeAfter monotonic in its argument again, and stops GetTimeBefore returning instants the expression never fires at. Fire times inside the repeated fall-back hour can therefore differ from 3.19.
  • DailyTimeIntervalTrigger no longer fires past its EndTimeUtc — the day-of-week advance resolved the advanced day's start-of-day through the instant-based offset overload while every sibling call site used the wall-clock policy overload, so the offset carried through the AddDays walk was stale once the walk crossed a transition, and an in-gap start-of-day resolved one transition delta too early. The EndTimeUtc check inside the same method consumed the mis-resolved instant before the downstream rescue corrected it, so with an end time between the wrong instant and the right one the trigger fired once past its configured end. Only triggers with a partial DaysOfWeek set were affected — the existing tests all use a full week, where the two resolutions coincide. (#​3196)
  • Calendars that exclude whole days mean the local day, in every zoneHolidayCalendar, AnnualCalendar, MonthlyCalendar and WeeklyCalendar built the boundary they walk as midnight at the offset the queried instant happened to carry, and advanced it with AddDays, which keeps that offset. Both assume a local day begins at midnight at the same offset the rest of the day carries, and a transition day is exactly the day where it does not. In America/Santiago, where the clocks move at midnight, the 23-hour day (2019-09-08) answered with the excluded instant it was asked about, and the 25-hour day (2019-04-06) overshot by 23 hours; in a zone that moves its clocks later in the day the answer lands a whole date out (Europe/Helsinki on 2024-03-31, asked from the afternoon, answered 2024-03-30); and a run of excluded days crossing a transition drifts by the transition delta. The boundary now resolves local midnight by naming the date rather than by adding twenty-four hours. CronCalendar never had the shape. (#​3478, fixes #​3457)
  • DailyCalendar.GetNextIncludedTimeUtc is jumped to rather than walked up to — it named the window's edges on the date the argument fell on and paired them with the argument's offset, while IsTimeIncluded converts into the calendar's zone first, so a question asked in UTC about a calendar keeping another zone's hours had the two disagree. The jump then landed on a window an offset away from the one being tested, the loop fell through to its last resort — one precision step at a time, bounded only by where the wrong window happened to end — and it could step clean over an included stretch. The measured reproduction, an inverted 21:00–22:00 America/Santiago calendar asked at 2019-04-07T01:30Z: 2019-09-09T00:00:59.999Z, five months late, reached a minute of wall clock at a time, against 2019-04-08T01:00Z in two passes now. The whole daily-calendar half of the new test fixture takes 1 m 52 s against the unfixed file and milliseconds against this one. The answer is now named from the window's own edges — the instant the day opens at, the second reading of that edge where the clock repeats it, the instant the clocks moved where a fall-back takes the clock back to before the window opened, and the day's own first instant — each checked against the calendar's own rule before it is taken. (#​3478, fixes #​3466)
    • Behavior change worth noting: the precisionStepMillis optimisation (#​2285) is removed, because a jump has nothing left to speed up and it was rounding the answer up by as much as a minute. GetNextIncludedTimeUtc answers are now exact: a 06:0022:00 calendar asked at 21:59Z returns 22:00:00.001Z where it returned 22:01:00.000Z, and asking about an instant the calendar already includes gives back the next millisecond rather than the next minute. The NestedCalendarTests performance test that came with #​2285 is unchanged and passes.
    • Behavior change worth noting: GetTimeRangeStartingTimeUtc and GetTimeRangeEndingTimeUtc are public and carried the same offset assumption in public — asked in UTC about a Santiago calendar they answered about the UTC date at +00:00, which no caller can use. They now read the date of the local day the instant falls in. For a calendar left on the default TimeZoneInfo.Local asked with a local value, the conversion is a no-op. No signature changed.
  • A trigger written as a plain object graph keeps its time zoneUseNewtonsoftJsonSerializer leaves RegisterTriggerConverters off by default, and with it off Json.NET's default contract wrote a TimeZoneInfo as its whole public surface (Id, DisplayName, BaseUtcOffset, all read-only), so reading it back set nothing and the trigger's getter fell through to TimeZoneInfo.Local. A trigger stored under Tokyo fired on whichever zone the reading machine was in, silently. Measured before the fix: CalendarIntervalTriggerImpl, RecurrenceTriggerImpl and CronTriggerImpl all came back on the reading machine's zone. An internal TimeZoneInfoConverter writes the id and reads both the id and the old object form, attached per property to members typed as a TimeZoneInfo — deliberately not on the serializer's converter list, since that list is consulted for a value's runtime type wherever it appears and a zone held in a job data map value would then lose the $type that path carries. The four private timeZoneInfoId helpers that were meant for this and never worked (DefaultContractResolver does not serialize private members) are gone. BLOB_TRIGGERS payloads written by BinaryObjectSerializer are unaffected: they are computed properties, so there was never a backing field for BinaryFormatter to match. (#​3505)
    • Found and deliberately not fixed: with the converters off, a DailyTimeIntervalTriggerImpl cannot be read back at all — TimeOfDay has no parameterless constructor, so Json.NET fails loudly on EndTimeOfDay. It predates this change, it fails rather than corrupting, and the ADO store reaches that path only for a trigger it serializes as a blob, so a shipped DailyTimeIntervalTrigger does not go through it.
  • The interrupt monitor interrupts only the fire instance it was created forJobInterruptMonitorPlugin interrupted by job key, so a monitor that elapsed cancelled every running execution of that job rather than the one it was watching. Worse, a vetoed fire's monitor was never cancelled at all, because the only cleanup point was TriggerComplete, which a vetoed fire never reaches — so every veto leaked a live monitor that would later interrupt an unrelated, healthy execution of the same job. It now calls Interrupt(fireInstanceId), cancels the monitor on veto through a job listener of its own, does not start a second monitor for a re-executed job sharing a fire instance id, and removes its own bookkeeping entry when it elapses. Both scenarios are exactly as analysed in the report. (#​3249, fixes #​3248)
    • Behavior change worth noting: IScheduler.Interrupt(fireInstanceId) now raises ISchedulerListener.JobInterrupted, matching the Interrupt(JobKey) overload — relevant to anyone calling the fire-instance overload directly. And AutoInterruptable is now read from the merged job data map, consistent with MaxRunTime, so a trigger's data map can opt a fire in or out of auto-interruption rather than only override the timeout.
  • A blank calendar name is no calendar name — a trigger whose CALENDAR_NAME was written as '' rather than NULL stopped firing entirely: every job store gates its calendar lookup on CalendarName is not null, so the empty string passed the gate, the lookup found nothing, and the fire was silently dropped. AbstractTrigger.CalendarName now stores a blank name as null, without trimming — the name is a lookup key against whatever AddCalendar stored, so trimming " holidays " would break a calendar registered with padding and create the same bug from the other direction. That one setter is the choke point for TriggerBuilder, both JSON converters and the ADO read-back, so databases already holding '' self-heal: the row rehydrates as "no calendar", the trigger fires again on the next acquisition, and the column is written back as NULL next time it is persisted. No migration script. TriggerDetailsUpdate.WithCalendarName normalizes separately, since both stores check the calendar exists before the value reaches the trigger setter. Both stores now also log when they skip a fire over a missing calendar, which turns this class of report from a mystery into a one-line diagnosis. The dashboard, which produced the empty string by rebuilding the trigger field by field out of its display projection, now edits the trigger JSON it was already handed — which also preserves the node pin and the real trigger type it used to drop. (#​3295, fixes #​3294)
  • Six bug fixes found by the 4.0 campaign, backported — each confirmed on 3.x with exact locations and mutation-verified on both test target frameworks: CronCalendar.GetNextIncludedTimeUtc never terminated from an excluded instant (it advanced with GetNextValidTimeAfter, which lands on another excluded time); CronCalendar's three-argument constructor dropped its timeZone, so the calendar evaluated its exclusion cron in machine-local time; transient-error classification short-circuited on DbException.IsTransient, which on any .NET 6+ host matches every driver exception — so the deadlock-1205 list, the SQLite busy/locked check and the timeout fallback were all dead code and retryable failures were classified permanent; a prefix trigger-group pause paused only the first matching group and a prefix resume could never clear what a prefix pause recorded; JobInterruptMonitorPlugin silently ignored a numeric MaxRunTime, falling back to the five-minute default with no log; and Newtonsoft deserialization populated read-only collections through their getters, so on the default configuration a DaysOfWeek subset came back as all seven days. (#​3334)
    • Behavior change worth noting: three of those change what fires when. Transient database failures are now retried where they were previously treated as permanent; a prefix trigger-group pause or resume now affects every matching group rather than the first; and a DaysOfWeek subset survives the plain Newtonsoft round trip. Deliberately not backported: the 4.x store-parity semantic alignments (pause-over-Error, ResumeAll marker clearing, sentinel visibility, exception re-wrap types), which change observable maintenance-branch behaviour.
  • ADO delegate parameter bindings, cluster recovery priority and LIKE wildcard handling — cluster failover recovery triggers lost their priority, because SelectInstancesFiredTriggerRecords was the only fired-trigger reader that never read PRIORITY and ClusterRecover assigns from exactly that reader, so every recovery trigger ran at priority 0, below the default 5, deprioritising recovery work precisely when a node has died. Four IDriverDelegate members failed on every provider because their bound parameter names never matched their SQL; nothing in the scheduler calls them, which is why it never surfaced, but they are public surface reachable from JobStoreSupport subclasses. And group matcher values are now escaped, with ! as the escape character because ESCAPE '\' is a MySQL syntax error while ! is a plain literal on all six supported databases. (#​3202)
    • Behavior change worth noting: group names containing % or _ now match literally in group matcher queries. Previously those characters acted as LIKE wildcards, so a matcher could list, pause, resume or delete groups it was not meant to match. The same correction means ResumeAll deletes only the _$_ALL_GROUPS_PAUSED_$_ sentinel row rather than a pattern in which all eight underscores were single-character wildcards.
  • PostgreSQL index realignment, and prefix-redundant indexes dropped everywhere — the PostgreSQL script had 9 of its 11 indexes not leading with sched_name, which every Quartz statement filters on first, and idx_qrtz_t_nft_st had its columns reversed(next_fire_time, trigger_state) against an acquire query that is two equalities then a range, so the index could never bound the scan on state or scheduler name. This is the shape behind slow-acquire-on-PostgreSQL reports. Verified on live PostgreSQL 16: the upgrade script and a fresh install converge on byte-identical pg_indexes sets, and a 160,000-trigger acquire-plan comparison moves all three predicates from post-filter into the index condition — buffer hits 136 → 57, discarded rows 61 → 0. Across the other dialects, indexes whose columns are a leftmost prefix of a wider same-table index are dropped, with the coverer named on every drop. IDX_QRTZ_J_GRP deliberately stays on 3.x, unlike 4.x, because its 4.x coverer does not exist here and it serves the group listings. The migration is optional and performance-onlydatabase/migrations/3.20/index_alignment_<dialect>.sql. (#​3203)
  • Database scripts are per-version, per-dialect migrations, and they are generated — every migration now lives in database/migrations/<version>/<name>_<dialect>.sql, one directly-runnable file per database instead of one file carrying five commented-out dialect blocks, with every statement guarded so re-running is a no-op (SQLite ADD COLUMN is the documented exception — SQLite has no conditional DDL). The scripts are generated from one description in the build, because six hand-written dialect variants is how they drift, and VerifyMigrations fails a checked-in script that no longer matches. database/README.md is the new index: run order, per-version status, and the old-path → new-path map reproduced below. The migrations previously had no test coverage at all; MigrationScriptTest now builds a 3.16-era schema from a checked-in baseline, applies 3.17 → 3.18 → 3.19 → 3.20 in order, applies each twice so the guards are exercised, and asserts the result matches what the current tables_<dialect>.sql produces, table for table, column for column, index for index — on all six databases. It caught two real defects on the way: PostgreSQL's index alignment used CREATE INDEX IF NOT EXISTS for three indexes that already exist in a 3.16 schema under the same name with different columns, so the guard silently kept the wrong shape — including idx_qrtz_t_nft_st; and MySQL's QRTZ_BLOB_TRIGGERS carried an InnoDB-auto-named inline index duplicating its primary key that the migration had left in place. It also backports the 2.5→2.6 QRTZ_CRON_TRIGGERS.TIME_ZONE_ID fix (#​1985), which never reached this branch. (#​3219, fixes #​3218)
  • The 3.x → 4.0 upgrade scripts live on main, and this branch links to them — they were briefly mirrored here, and a mirror goes stale silently every time 4.x's schema moves. A confidently wrong upgrade script is worse than one that is plainly somewhere else, so database/migrations/4.0/ is not on this branch and database/README.md points at https://github.com/quartznet/quartznet/tree/main/database/migrations/4.0 in every place it used to link to the folder. (#​3373, and [#​3326](ht

Note

PR body was truncated to here.


Configuration

📅 Schedule: (in timezone Asia/Shanghai)

  • Branch creation
    • "before 5:00am,before 10am,before 3pm,before 8pm"
  • Automerge
    • At any time (no schedule defined)

🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.

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.

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