| Name | Modified | Size | Downloads / Week |
|---|---|---|---|
| Parent folder | |||
| README.md | 2026-09-19 | 6.6 kB | |
| v4.1.1 source code.tar.gz | 2026-09-19 | 7.4 MB | |
| v4.1.1 source code.zip | 2026-09-19 | 9.1 MB | |
| Totals: 3 Items | 16.5 MB | 8 | |
Quartz.NET 4.1.1 is a maintenance release with one real bug in it: a scheduler that published traces produced one trace per process rather than one per firing, and it grew for as long as the process lived. Nothing about how a trigger fires changed, the schema is 4.0's, and the public API gained two overloads and lost nothing.
:::shell
dotnet add package Quartz --version 4.1.1
What changed
- A firing is a trace of its own again — subscribing to
ActivitySource("Quartz")gave a single unbounded trace: every span the scheduler's loop opened became the parent of the next one, and eachQuartz.Job.Executehung off whichever was current when it was dispatched. A day of a quiet staging pod arrived at the backend as two trace ids and a tree several thousand spans deep, with the jobs' own EF Core andHttpClientspans buried under thousands ofQuartz.JobStore.AcquireNextTriggers. The documentation has promised the opposite since 4.0 — the firing is its own trace root — and nothing tested it, because the observability suite reads tags off one span at a time and a span's parent is not a tag.
Two independent defects made that trace, either sufficient on its own. The store decorator started its span in the synchronous override and stopped it inside the asynchronous continuation: Activity.Current is an AsyncLocal, so the start wrote onto the caller's execution context and the stop put the parent back onto the continuation's, which is discarded — leaving the caller with the span current for good, once per iteration of a loop that lives as long as the process. And the execute span took whatever activity was ambient as its parent, so merely starting the scheduler inside a request decided every firing's trace for the life of the application.
Quartz.Job.Execute and Quartz.Job.Veto are now created with an empty parent context — so a parent-based sampler is asked about a root rather than about a context that is being discarded — with Activity.Current cleared around the start that re-reads it, and restored when the span stops. The store spans are started and stopped on one execution context. The three background loops — the scheduler thread, the cluster manager and the misfire handler — clear Activity.Current on entry, because their task captured the execution context of whoever called Start() and they outlive that call by the whole life of the process. Store spans still belong to whoever made the call, so scheduler.ScheduleJob(…) inside a request stays in that request's trace. (#3797, [#3799])
On the reporter's own repro, which expected 7 traces and 7 roots:
| traces | largest trace | executions | trace roots | |
|---|---|---|---|---|
| 4.1.0 | 2 | 30 spans | 7 | 0 |
| 4.1.1 | 31 | 1 span | 7 | 7 |
Quartz.Job.Vetoalso gained theActivityLinkback to the call that scheduled the firing, which it never carried; a refused fire is now walked back from exactly as an executed one is.- The link is handed to
CreateActivityrather than added after it.ActivityListener.Sampleruns while the activity is created, so a link added afterwards was one no sampler ever saw — and sampling a consumer span by the trace that produced it is the reason links are given to samplers at all. -
Behavior change worth noting: this is a change in the shape of what an exporter receives. A dashboard or saved query that searched for job executions by the trace they were nested in will not find them; they are roots. This is the documented behaviour finally arriving, not a new convention.
-
An execution limit or a job timeout can be read from the container — 4.x dropped the
AddQuartzoverloads that handed the callback anIServiceProvider, and the replacement the migration guide led with (readIConfigurationat registration time) bypassesConfigure,PostConfigureandIValidateOptions. Everything else onIQuartzBuildereither lands in named options or has aFunc<IServiceProvider, …>shape;UseExecutionLimitsandAddJobTimeoutcomputed their value eagerly and registered an internal type, so neither could be reached from the container at all. Both now have a shape that is handed one when the scheduler is built. Precedence is untouched — both shapesTryAdd, so the first declaration in code wins and code still beats thequartz.executionLimit.*keys. The migration guide now leads with the options pattern rather than with reading a section yourself, and names the caveat that made the old advice misfire: a scheduler's options are configured under its own name, so a bareAddOptions<T>()silently configures nothing of a named scheduler's. 3.x is unaffected — its four(configurator, IServiceProvider)overloads are still there. (#3794, d523c898bc) -
The documentation's dead links are fixed — the cron pages pointed at FreeFormatter.com, which is gone; they now answer with the library itself, and a handful of other rotted links across the 1.x–4.x trees were fixed with them. (#3796)
Public API — two additions, nothing else
| Added | What it is |
|---|---|
QuartzBuilderExtensions.AddJobTimeout(IQuartzBuilder, Func<IServiceProvider, TimeSpan?>) |
the default job timeout, computed once the container exists |
QuartzBuilderExtensions.UseExecutionLimits(IQuartzBuilder, Action<IServiceProvider, ExecutionLimitsBuilder>) |
execution-group limits, configured with services in hand |
v4.1.0..v4.1.1 baseline diff: 2 added lines, 0 removed. Every package's dependency list is byte-identical to 4.1.0 — the Microsoft.Extensions.* floors stay at 10.0.0, so an application pinned a servicing patch lower still restores.
One source-level wrinkle the second overload brings: AddJobTimeout(null) written with a literal null is now ambiguous between the two shapes. It means what AddJobTimeout() means; write that. Every other spelling — a TimeSpan, a TimeSpan? variable, the named-argument form, no argument at all — is unchanged, and UseExecutionLimits(null) is unaffected.
Upgrading
From 4.1.0: dotnet add package Quartz --version 4.1.1, and nothing else. There is no schema change, no configuration change, and no code change to make.
If you worked around [#3797] by leaving AddSource("Quartz") out and opening a root in an IJobExecutionMiddleware, you can drop the middleware and subscribe normally — and keep the Quartz.JobStore.* spans, which are now short traces of their own rather than noise in yours.
Full changelog: https://github.com/quartznet/quartznet/compare/v4.1.0...v4.1.1