Download Latest Version v4.1.1 source code.zip (9.1 MB) Google Add to Preferred Sources
Home / v4.1.0
Name Modified Size InfoDownloads / 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 extends ISchedulerRegistry, 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.LookupScheduler now 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 within SchedulerRestartOptions.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 next Restart finishes 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/2 parses and means what 2/2 means (a step through the week, not 3.x's fortnight), and MON-FRI/2 is 2-6/2 where 4.0 silently read it as MON-FRI. (#3732, [#3733] [85214e4499…e29f7c4283]). The century walk of CronCalendar.GetNextIncludedTimeUtc (#3690) shipped in 4.0.1.
  • HTTP API — POST …/triggers/{group}/{name}/update-details edits 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 a null clears it), and HttpScheduler.UpdateTriggerDetails no longer throws — Context and ListenerManager are the two members a wire cannot carry (#3681, [#3735]). take says in the OpenAPI document why it is a string: a page size, or all for everything up to MaxPageSize (#3682, [#3736]). GetStatus(ct) and GetSchedulerInstanceId(ct) are the asynchronous twins of the two IScheduler properties a wire cannot answer without blocking — default interface members that answer the property locally, one round trip on HttpScheduler, and on the container's deferred scheduler they build it on first use where the properties throw (#3684, [#3738]). QuartzHttpApiOptions.IsJobTypeAllowed and 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 a 403 naming 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 the Quartz:Schedule section 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() beside AddQuartzHttpClient("acme", …) now fronts the scheduler in the other process honestly: it is listed with the new origin Remote, 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's Status while 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 reports Unknown for the ones that did not answer, the pages page instead of asking for everything, and a second AddQuartzHttpClient under the same name is refused rather than silently winning. Execution history is now Quartz's rather than the dashboard's: IExecutionHistoryStore in core with an in-memory default bounded by age and count, a recorder AddQuartzExecutionHistory() installs against every scheduler, three GET …/schedulers/{name}/history/… routes the HTTP API serves, and a read-only reader of them that AddQuartzHttpClient registers per target. QuartzHttpApiOptions.ReadOnly turns 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 Information line, 9007, naming the caller (HttpContext.User.Identity.Name, or (anonymous)), the operation, the scheduler and the route — until now a POST …/shutdown that worked was recorded nowhere. The dashboard's Action Log entries and their 9100/9101 lines 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/2 parses (4.0 refused it; 3.x read it as every second week) and MON-FRI/2 fires 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. A QuartzSchedulerBuilder with a provider registered but no factory refuses to build (see [#3730]). GetNextInvalidTimeAfter returns null where it used to return a valid instant (4.0.1). A shut-down container scheduler injected as IScheduler re-points to the generation ISchedulerRuntime.Restart built. Mapping the HTTP API now records execution history in memory, bounded (24 h, 2,000 rows per scheduler); ExecutionHistoryOptions.MaxEntriesPerScheduler = 0 records nothing. AddQuartzHttpClient throws on a scheduler name it already registered. A 4.0 client parsing GET …/schedulers from a 4.1 host that fronts a proxy sees the origin "Remote" it does not know — upgrade the client. AddQuartzDashboard() no longer registers DashboardLiveEventsPlugin; a reverse proxy needs to forward {DashboardPath}/hub only for SignalR clients of your own, not for the pages. Log events 9100 and 9101 end 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 + CheckinMisfireThreshold has 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 at DbRetryInterval, 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 than DbRetryInterval — 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. CheckinMisfireThreshold past the timer ceiling is now refused at startup, like CheckinInterval. (#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 DbRetryInterval as 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 — only RequestRecovery would ever have replayed it — and a completion reported after the store had closed was aborted, leaving the trigger BLOCKED for 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 — before Start(), so the recovery sweep found nothing to recover. ReplaceTrigger now 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 stored BLOCKED behind an execution still in flight rather than WAITING beside 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. DbLockHandler now logs event 3716 once per acquisition that outlives AdoJobStoreOptions.LockWaitWarningThreshold (30 s by default; null reports nothing), every acquisition is timed on quartz.jobstore.lock.wait.duration, and quartz.jobStore.commandTimeout — which 3.22 adds — translates to AdoJobStoreOptions.CommandTimeout where 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, and threshold >= DbRetryInterval is no longer a relation an operator has to know about. The 3.x configuration reference's clusterCheckinInterval default (7500, not 15000) and the missing clusterCheckinMisfireThreshold row were fixed on the way. (a5b2197e5d)
  • Two schedulers locking through Redis are proven not to double-fire — the redis integration leg now runs two clustered nodes over one PostgreSQL store with RedisLockHandler as 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 listed Quartz.Extensions.Redis as 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, so quartz.jobStore.dbRetryIntreval started the scheduler with the default in force and said nothing — which is also how quartz.jobStore.commandTimeout went 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 = false allows 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.Cron package was sized (#3720): mechanical at ~5,000 lines on net8.0;net10.0, and not shipped — no consumer would take it today. The report is on the issue.
  • Quartz depends on the logging abstractions, not the logging package — Quartz writes log events and never builds the pipeline that carries them, so AddQuartz registers ILogger<> and no ILoggerFactory at all; wherever it needs a factory it asks the container and falls back to LogProvider. Nine tests pin that the order of AddQuartz and AddLogging does not matter and that a host's providers are never dropped. A QuartzSchedulerBuilder handed a bare ILoggerProvider without AddLogging now refuses at Build() with the fix in the message, where it used to log through a factory Quartz no longer supplies. (#3730, [#3745])
Source: README.md, updated 2026-09-13