| Name | Modified | Size | Downloads / Week |
|---|---|---|---|
| Parent folder | |||
| Quartz.NET-4.1.0.zip | 2026-09-14 | 23.9 MB | |
| README.md | 2026-09-13 | 18.0 kB | |
| v4.1.0 source code.tar.gz | 2026-09-13 | 7.4 MB | |
| v4.1.0 source code.zip | 2026-09-13 | 9.0 MB | |
| Totals: 4 Items | 40.4 MB | 0 | |
Quartz.NET 4.1 is an additive minor: every public change is a new type, a new member on a type we own, or a default interface member; the schema is 4.0's; a 4.0 application upgrades by changing the version. The headline is that a scheduler can now be added to, removed from and restarted in a running container — the one item the 4.0 roadmap deferred as "a 4.1 concern" — and around it the additive follow-ups filed during the 4.0 release candidates, an API reference for 4.x, and an honest Wolverine page now that Wolverine has a cron of its own.
:::shell
dotnet add package Quartz --version 4.1.0
Highlights
- Schedulers can be added, removed and restarted while the host runs —
ISchedulerRuntime(it extendsISchedulerRegistry, as the 4.0 guide promised) builds a scheduler from a recipe into a container of its own that resolves the application's services, jobs and options from the application's container, binds it where the HTTP API, the dashboard and the health checks already look, drains it before it is replaced, and refuses the cases that cannot be made safe: a name the container registered, a bound name, a recipe that supplies an instance part, a restart whose drain gave up.ISchedulerFactory.LookupSchedulernow builds a registered-but-unstarted scheduler on lookup. A restart builds the next generation first (a configuration error leaves the old one running), drains the old one withinSchedulerRestartOptions.DrainTimeout(30 s by default) and only then initialises the new store — because the store's recovery sweep is per scheduler name, not per instance — and a drain that gives up leaves the name shut down and reported with no status until the nextRestartfinishes the job. A recipe that hands over a job store, thread pool, job factory or instance-id generator as an object is refused before anything is built, since a shut-down instance cannot be re-initialised. (#3338; add/remove [#3725], restart [#3734]) - Cron —
MON/2parses and means what2/2means (a step through the week, not 3.x's fortnight), andMON-FRI/2is2-6/2where 4.0 silently read it asMON-FRI. (#3732, [#3733] [85214e4499…e29f7c4283]). The century walk ofCronCalendar.GetNextIncludedTimeUtc(#3690) shipped in 4.0.1. - HTTP API —
POST …/triggers/{group}/{name}/update-detailsedits a trigger in place (description, priority, job data, calendar, misfire instruction with its family, preferred node, execution group, retry policy; a field absent from the body is left alone and anullclears it), andHttpScheduler.UpdateTriggerDetailsno longer throws —ContextandListenerManagerare the two members a wire cannot carry (#3681, [#3735]).takesays in the OpenAPI document why it is a string: a page size, orallfor everything up toMaxPageSize(#3682, [#3736]).GetStatus(ct)andGetSchedulerInstanceId(ct)are the asynchronous twins of the twoISchedulerproperties a wire cannot answer without blocking — default interface members that answer the property locally, one round trip onHttpScheduler, and on the container's deferred scheduler they build it on first use where the properties throw (#3684, [#3738]).QuartzHttpApiOptions.IsJobTypeAllowedand its dashboard twin let an operator name which job types a request may schedule — a predicate over the type name as the request spelled it, never a resolved type; a refused name is a403naming the type and a warning in the log, and the default is unchanged (#3685, [#3741]). - Scheduling files — a trigger's preferred node can be declared in
quartz_jobs.json, in theQuartz:Schedulesection and in the XML file (<preferred-node>;*pins it to whichever node fires it first), and the XML format catches up with<execution-group>and<retry-policy>. The schema stays 2.0: the elements are optional and a 3.x file loads unchanged. The freeze on the XML format is redrawn as a freeze on trigger kinds, not settings. (#3683, [#3739]) - API reference for 4.x is published again at https://docs.quartz-scheduler.net/apidoc/4.x/, generated with docfx from the XML documentation every public member carries, and a doc comment that no longer matches its member fails the docs build. (#3686, [#3740])
- A scheduler reached over HTTP is a supported dashboard target, history included —
AddQuartzDashboard()besideAddQuartzHttpClient("acme", …)now fronts the scheduler in the other process honestly: it is listed with the new originRemote, every page drives it, the History page and the misfire tile read the history the target's process keeps, and Live Logs says what a wire does not carry yet. Underneath: the scheduler repository no longer reads a remote scheduler'sStatuswhile holding its lock — one unreachable target used to stall every scheduler lookup in the process, the HTTP API's own included, for the client's timeout — the registry asks all schedulers concurrently under one two-second budget and reportsUnknownfor the ones that did not answer, the pages page instead of asking for everything, and a secondAddQuartzHttpClientunder the same name is refused rather than silently winning. Execution history is now Quartz's rather than the dashboard's:IExecutionHistoryStorein core with an in-memory default bounded by age and count, a recorderAddQuartzExecutionHistory()installs against every scheduler, threeGET …/schedulers/{name}/history/…routes the HTTP API serves, and a read-only reader of them thatAddQuartzHttpClientregisters per target.QuartzHttpApiOptions.ReadOnlyturns the whole mutating surface of the API off in one switch — mutation is a property of the route, so the two bulk fetches stay reads. The design for the rest — live events over the wire, an audit of operator actions, store-attached and dial-out targets, a fleet pane — is recorded on [#3387] and filed as 4.2 issues (#3768–#3775). (#3387, [#3776]) - Who changed what is on record. Every mutating HTTP API request that succeeds logs one
Informationline,9007, naming the caller (HttpContext.User.Identity.Name, or(anonymous)), the operation, the scheduler and the route — until now aPOST …/shutdownthat worked was recorded nowhere. The dashboard's Action Log entries and their9100/9101lines now say where an action landed: the scheduler's origin, the node behind it, and whether the action was node-local (start, stand-by, shutdown and interrupting a firing) — and interrupting a firing from Currently Executing is recorded at all, which it was not. (#3769, [#3784]) - The Wolverine page no longer says Wolverine has no cron: since 6.34 it does (
opts.Schedules.ScheduleRecurring, over Cronos), and the page now puts the two side by side in one table, says plainly when Wolverine's own schedule is the right answer, and keeps the recipe for what a Quartz trigger adds — with a seventh example part running Wolverine's schedule beside Quartz's. (#3719, [#3737])
Public API — additive only
| Added | What it is |
|---|---|
ISchedulerRuntime : ISchedulerRegistry — Add, Remove, Restart |
schedulers in a running container; resolve either interface and you get the same object |
SchedulerAddOptions, SchedulerRestartOptions (readonly record structs), SchedulerRestartException |
their options and the one refusal a caller can act on |
IScheduler.GetStatus(ct), IScheduler.GetSchedulerInstanceId(ct) — default interface members |
the asynchronous twins of the two properties; a scheduler written outside this repository needs no change |
HttpScheduler.GetStatus / GetSchedulerInstanceId overrides |
one round trip each, no blocked thread |
QuartzHttpApiOptions.IsJobTypeAllowed, QuartzDashboardOptions.IsJobTypeAllowed |
an allow-list over the job-type name a request spells |
SchedulerOrigin.Remote; SchedulerRegistration.SchedulerInstanceId (init) |
a scheduler reached through a proxy, and the node a listing asked it for — asynchronously, under a deadline, never through the blocking property |
QuartzHttpApiOptions.ReadOnly |
every mutating route answers 403 problem details; the two bulk fetches stay reads |
QuartzHttpApiOptions.EventStreamHeartbeatInterval |
how long the event stream may be silent before a heartbeat frame; 15 s by default, set it below the proxy's idle timeout |
IExecutionHistoryStore, ExecutionHistoryEntry, MisfireHistoryEntry, ExecutionHistoryQuery, MisfireHistoryQuery, ExecutionHistoryOptions, AddQuartzExecutionHistory() |
the history seam in core; IDashboardHistoryStore keeps working as its adapter in both directions |
Nothing was removed or reshaped (v4.0.1..v4.1.0 baseline diff: 96 added lines, 0 removed). The one dependency change: Quartz now depends on Microsoft.Extensions.Logging.Abstractions rather than Microsoft.Extensions.Logging; an application that took LoggerFactory or AddLogging transitively from Quartz adds the package itself (#3730, [#3745]).
Behaviour changes worth noting
MON/2parses (4.0 refused it; 3.x read it as every second week) andMON-FRI/2fires on three days, not five — neither can be reported to you, both parse; the migration guide's 4.0 → 4.1 section has the audit query. AQuartzSchedulerBuilderwith a provider registered but no factory refuses to build (see [#3730]).GetNextInvalidTimeAfterreturnsnullwhere it used to return a valid instant (4.0.1). A shut-down container scheduler injected asISchedulerre-points to the generationISchedulerRuntime.Restartbuilt. Mapping the HTTP API now records execution history in memory, bounded (24 h, 2,000 rows per scheduler);ExecutionHistoryOptions.MaxEntriesPerScheduler = 0records nothing.AddQuartzHttpClientthrows on a scheduler name it already registered. A 4.0 client parsingGET …/schedulersfrom a 4.1 host that fronts a proxy sees the origin"Remote"it does not know — upgrade the client.AddQuartzDashboard()no longer registersDashboardLiveEventsPlugin; a reverse proxy needs to forward{DashboardPath}/hubonly for SignalR clients of your own, not for the pages. Log events9100and9101end with(origin …, node …)— a pipeline parsing the old template needs the new one.
Also
- A failed cluster check-in is retried inside the window its peers give it — a peer writes a node off once
CheckinInterval+CheckinMisfireThresholdhas passed since the row it last wrote (15 s on the defaults), and the cluster manager's sleep after a failed check-in was floored atDbRetryInterval, also 15 s, so one database blip during a check-in had a live node recovered by its peers: its next row went out at 22.5 s, 7.5 s after they had stopped trusting it. Inherited from Java Quartz. A failed check-in is now retried inside what is left of the window — half of it each time, never later thanDbRetryInterval— and only once the window has closed does the ordinary back-off apply; the retries are timed from the last check-in that reached the database, not from the stamp a failed read of the state table leaves. Defaults are unchanged, so the 15-second failover latency stays.CheckinMisfireThresholdpast the timer ceiling is now refused at startup, likeCheckinInterval. (#3777, [#3778]; 3.22 carries the same change on 3.x) - Behavior change worth noting: a node whose check-in fails makes up to ~9 connection attempts and logs three error lines in the first 15 s of an outage instead of one, then backs off
DbRetryIntervalas before. - A shutdown that does not wait for its jobs no longer loses a firing — the new two-node Redis fixture caught it:
Shutdown(waitForJobsToComplete: false)closed the thread pool to new work before the scheduler loop had finished its iteration, so a firing the loop had already committed to the store (fired-trigger row written, trigger advanced) was refused by the pool and never ran — onlyRequestRecoverywould ever have replayed it — and a completion reported after the store had closed was aborted, leaving the triggerBLOCKEDfor a peer's cluster recovery to settle. The loop is now halted and awaited before anything it depends on is torn down, and an unwaited shutdown gives the executions it dispatched two seconds to report their completions before the store closes; the waited form drains as before. On the fixture: 160 of 160 firings with a node leaving mid-run without waiting. (#3746, [#3754]) - Behavior change worth noting:
Shutdown(waitForJobsToComplete: false)— the hosted service's default — can take up to two seconds longer when a job is in flight. - A job that requested recovery is recovered after a hard kill when the application declares its jobs and triggers through
AddJob/AddTrigger. Declared content is applied at creation, and a trigger the store already held was applied as a reschedule that deleted every fired-trigger row of the key — beforeStart(), so the recovery sweep found nothing to recover.ReplaceTriggernow deletes the trigger row only; unscheduling still takes the fired rows with it, and the replacement of a trigger whose job disallows concurrent execution is storedBLOCKEDbehind an execution still in flight rather thanWAITINGbeside it. The clustered case had a second defect: a node's first check-in handed its own state row to recovery, and the deferral grace period judged that row by its old timestamp — the node's own record is never deferred now. A SQLite reproduction of the restart runs without Docker. (#3759) - A lock wait says how long it has been waiting, and every statement can be bounded. A lock statement queued behind a dead database session — a node whose network was cut while it held
TRIGGER_ACCESS— returns nothing and throws nothing, so a cluster stopped scheduling in silence.DbLockHandlernow logs event3716once per acquisition that outlivesAdoJobStoreOptions.LockWaitWarningThreshold(30 s by default;nullreports nothing), every acquisition is timed onquartz.jobstore.lock.wait.duration, andquartz.jobStore.commandTimeout— which 3.22 adds — translates toAdoJobStoreOptions.CommandTimeoutwhere it used to be accepted and silently dropped. Neither ends the wait; a command timeout does, and the troubleshooting page says how to tell the case apart in each database and which server-side settings free the lock. (#3764, [#3766]) - A failed check-in is retried inside the window its peers give it. A peer writes a node off once interval + threshold has passed (15 s on the defaults), and the cluster manager's sleep after a failed check-in was floored at
DbRetryInterval— also 15 s — so a check-in that failed at 7.5 s was next attempted after the peers had already recovered the node: fired rows deleted, recovering jobs fired again,[DisallowConcurrentExecution]no longer holding. Inherited from Java Quartz rather than a regression. While the window is open a failed check-in is retried inside it, half of what is left each time; only once it has closed does the ordinary back-off apply. Defaults and the documented 15-second failover latency are unchanged, andthreshold >= DbRetryIntervalis no longer a relation an operator has to know about. The 3.x configuration reference'sclusterCheckinIntervaldefault (7500, not 15000) and the missingclusterCheckinMisfireThresholdrow were fixed on the way. (a5b2197e5d) - Two schedulers locking through Redis are proven not to double-fire — the
redisintegration leg now runs two clustered nodes over one PostgreSQL store withRedisLockHandleras the only mutual exclusion, 250 firings across a[DisallowConcurrentExecution]job and an ordinary one, and asserts no firing doubled, none was lost, the non-concurrent job never overlapped and both nodes fired; a second fixture shuts one node down mid-run and checks the survivor and the closed multiplexer. 4.0.0 listedQuartz.Extensions.Redisas the least exercised package; it no longer is. (#3722, [#3744]) - 3.21.0 on the 3.x line carries the three fixes held back from 3.20.1 (see its notes).
- A misspelled
quartz.jobStore.*key fails startup instead of being ignored. 4.0 read the keys it knew into typed options and did nothing with the rest, soquartz.jobStore.dbRetryIntrevalstarted the scheduler with the default in force and said nothing — which is also howquartz.jobStore.commandTimeoutwent missing before it was bridged. A persistent store now refuses a key under that prefix that nothing reads, by name, where a misconfiguration already fails:Unknown configuration property 'quartz.jobStore.dbRetryIntreval'. It is not a setting of the ADO.NET job store ….quartz.checkConfiguration = falseallows it through, as for every other unknown key; a store of your own keeps failing in the binder that writes leftover keys onto it, as 3.x did. (#3767) - Behavior change worth noting: a configuration file carrying a
quartz.jobStore.*typo that 4.0 tolerated stops the scheduler from starting. - A zero-dependency
Quartz.Cronpackage was sized (#3720): mechanical at ~5,000 lines onnet8.0;net10.0, and not shipped — no consumer would take it today. The report is on the issue. Quartzdepends on the logging abstractions, not the logging package — Quartz writes log events and never builds the pipeline that carries them, soAddQuartzregistersILogger<>and noILoggerFactoryat all; wherever it needs a factory it asks the container and falls back toLogProvider. Nine tests pin that the order ofAddQuartzandAddLoggingdoes not matter and that a host's providers are never dropped. AQuartzSchedulerBuilderhanded a bareILoggerProviderwithoutAddLoggingnow refuses atBuild()with the fix in the message, where it used to log through a factory Quartz no longer supplies. (#3730, [#3745])