Quartz.NET 4.0 is the first major version since 3.0, and the first that assumes a modern .NET: it targets net10.0 only, it is asynchronous end to end, and the container builds the scheduler instead of a factory reading type names out of a string bag. A public surface that had grown for a decade got a full pass — one word per concept, one shape per operation, and nothing public that was never a contract.
This is an alpha. It is complete enough to run and to port an application against, and the API is close to final, but it is a pre-release: names can still move if feedback says they should, and there is no compatibility promise between alpha builds. Do not put it in production yet. Do please try it against a real application and say what breaks or reads badly — that is what this build is for.
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 — every breaking change, with before and after, and an appendix that indexes every removed name
- Database schema changes — what to run, per version and per dialect
Before you start
Four things have to be true before a 3.x application will build and run on 4.0:
- The project targets
net10.0. There is nonetstandard2.0build and no Full Framework.configsupport. - The schema migration has been run. Four columns on
QRTZ_TRIGGERSand one onQRTZ_FIRED_TRIGGERSwere optional in 3.x and are required now, so 4.0 does not probe for them.schema_30_to_40_upgrade_<dialect>.sqlfolds in everything from 3.17 onward. - The merged packages are dropped from the project file.
Quartz.Extensions.DependencyInjection,Quartz.Extensions.HostingandQuartz.Serialization.SystemTextJsonare part ofQuartznow.Quartz.Serialization.JsonisQuartz.Serialization.Newtonsoft, andQuartz.OpenTracingis dropped in favour ofOpenTelemetry.Instrumentation.Quartz. - Daylight saving fire times have been reviewed. Interval cron expressions fire through both halves of a repeated fall-back hour instead of skipping one, and
CalendarIntervalTriggerwithPreserveHourOfDayAcrossDaylightSavingssteps in local wall-clock time and no longer drifts in zones whose offset is not a whole hour. Any schedule that crosses a transition is worth re-checking.
Highlights
The container builds the scheduler
- No reflective construction from type-name strings, and no process-global state. DI and hosting live in the core
Quartzpackage.StdSchedulerFactory,DirectSchedulerFactory,quartz.configfile discovery,SchedulerRepository.InstanceandDBConnectionManager.Instanceare gone. Flatquartz.*keys still work — they are translated into typed options by one component that understands them, and a misspelled key is rejected with a message rather than ignored. QuartzSchedulerBuilderis the DI-free entry point and is the same builderAddQuartzuses, so there is one configuration API rather than two that resemble each other. Its configuration members return the builder's own type, soCreate().UseInMemoryStore().BuildScheduler()is one expression.- Every component is chosen the same way.
UseJobFactory,UseTypeLoader,UseThreadPool,UseJobStore,UseTimeProvider,UseInstanceIdGenerator— each with a type, an instance and a factory overload, and each keyed correctly for a named scheduler. - Options are validated at startup, through
ValidateOnStart, so a bad value failsHost.Build()with every failure listed instead of throwing later from inside a factory. - Constructing a scheduler no longer starts a thread, so resolving the object graph or running
ValidateOnBuilddoes not spin one up.
Listings are queries
QueryJobs,QueryTriggers,QueryJobGroups,QueryTriggerGroupsandQueryCalendarNamestake a query record and return aPagedResult<T>withItems, an exactHasMoreand an optionalTotalCount. A table with a hundred thousand triggers no longer loads in one go.Takedefaults to 250; asking for everything is an explicitTake = int.MaxValue.- Listings project.
JobHeaderandTriggerHeadercarry the name, group, state, fire times, priority, calendar and execution group a listing needs, so a dashboard page renders without deserializing a single job data map. - Listings filter by name as well as group, and the ADO store translates an equality matcher to
=rather thanLIKE, so a group literally named50%matches itself.IsJobGroupPausedasks the store about one group instead of listing every paused group and searching it. - Bulk fetch by key —
GetJobDetails(keys)andGetTriggers(keys)turn N round trips into one, over ADO.NET and over HTTP alike. - The eight removed
ISchedulerlisting members come back as extension methods with the same names and signatures, so existing call sites keep compiling. One behavioural difference: a null matcher now throws instead of silently narrowing the listing to theDEFAULTgroup.
What is running, and what state it is in
TriggerState.Executingis reported directly, and with a persistent store it is visible from every node.Blockednarrows to its real meaning: a different trigger of the same[DisallowConcurrentExecution]job is running. (closes #1416)- Fire instances are a listing.
QueryFireInstances(FireInstanceQuery)returns aPagedResult<FireInstance>— fire instance id, trigger, job, node, state, scheduled and actual fire time, execution group — answered cluster-wide by a persistent store. It replacesGetCurrentlyExecutingJobs, which could only ever speak for the current process. There is an HTTP endpoint for it and the dashboard tile reads it. (closes #3205) - Job instantiation failures name the trigger.
JobInstantiationExceptioncarries the job detail, the trigger and the fire instance id rather than interpolating a key into a message. - Triggers entering the error state are reported.
ISchedulerListener.TriggerInError/TriggersInErrorfire on every transition intoTriggerState.Error, including two in the ADO store that previously reached nothing at all.
Scheduling
- Misfire instructions are per-family enums —
WithMisfireInstruction(SimpleTriggerMisfireInstruction.FireNow)instead of an untypedintand a method name per instruction. The stored numbers are unchanged. TimeOnlyandDateOnlyreplace Quartz's ownTimeOfDay, andTimeProviderreplacesSystemTime, so faking the clock in a test uses the BCL. The scheduler's clock is injected rather than ambient, andAddQuartzpicks up aTimeProviderregistered in the container.- Builders carry the job type.
JobBuilder<TJob>andTriggerBuilder<TJob>letUsingJobDataname a job property with an expression instead of a string key, and the nineUsingJobDataoverloads collapse to one. - One way to say each thing — one
WithIntervalper builder instead of theWithIntervalIn*families,Createas the factory verb everywhere, one family ofWithXScheduleextensions, andWithCalendarNamewhereModifiedByCalendarused to be. - Pause, resume and reset a set of keys in one call, in one lock and one transaction, answering with the keys it applied to.
TriggerDetailsUpdateedits a stored trigger's description, priority, job data, calendar, misfire instruction, preferred node or execution group without resetting its fire times or its state.- A preferred node is a value.
PreferredNodeis a readonly struct withAuto/None/Forinstead of a nullable string, and it survives serialization — a pinned trigger keeps its node over HTTP, in the dashboard, and in a custom store that persists JSON. - Execution limits are built once and then frozen, and a job store is handed both the limits and the slots to spend them, so a store of your own can honour execution groups.
Persistence
- The job store speaks the scheduler's verbs. Store/Remove/Retrieve became Add/Delete/Get/Schedule, so the two halves of one operation finally have the same name, and the activity names follow.
RAMJobStoreis sealed andDelegatingJobStoreis the supported decorator, alongsideDelegatingScheduler. Both forward every member and declare each one virtual.UseJobStore<T>()is a first-class seam. A job store of your own is registered like any other component,StoredTriggerStateand its resolution rule are public inQuartz.Extensibility, and the SPI has how-to documentation. There is still no document-database store in the box. (towards #814)- Enlisted and ambient transactions are first class — a job store can join the connection you already opened, so saving your own data and scheduling the job that acts on it commit together or not at all.
- The driver delegate speaks records —
FiredTriggerRecord,StoredTriggerHeader,DriverDelegateContext,TriggerAcquireResult,SchedulerStateRecord,FiredTriggerQuery— and a typedStoredTriggerStateinstead of raw strings. The stored values are unchanged, so a 3.x node and a 4.0 node can share a cluster while you roll one over. ValidateSchemais part ofIDriverDelegate, so a delegate that is not aStdAdoDelegateno longer silently skips the check that was asked for. TheSupports*Columnprobes are gone with the optional columns they tested.- Locks are a
SchedulerLock, not a string, semaphores are told which scheduler they lock for, and both shipped row-lock handlers back off on the injected clock, so their retry behaviour is testable. - Misfired triggers are recovered in batches, replacing a round trip per trigger.
Over the wire
Quartz.HttpClientdrives a remote scheduler:HttpScheduleris anISchedulerthat turns each call into an HTTP request. WithAddQuartzHttpApi/MapQuartzHttpApiserving the other end, this is the supported replacement for .NET Remoting, which is removed.- One wire contract. The HTTP API, its client and the dashboard share one definition of every payload rather than three that drifted, and the enums on the wire are spelled with their names. The wire format itself is unchanged; a snapshot test now pins it.
- The OpenAPI document describes the whole payload. Five properties the server had always been sending were missing from the schema, and
RecurrenceTriggerwas missing from the trigger discriminator; a test compares the published schema against what each trigger type really serializes, in both directions. - The dashboard's client speaks Quartz's vocabulary —
TriggerStateinstead of a string,PagedResultinstead of a paging model of its own, andJobKeyDto/TriggerKeyDtoinstead of a loose(scheduler, group, name)triplet on sixteen methods.
Observability
- Every span and measurement attribute is named under
quartz., and theActivitySourceandMeternames are public constants you can subscribe with. Saved dashboards and backend queries need updating — the migration guide carries an old-to-new table for exactly this. - Job execution is recorded as a duration histogram in seconds, following OpenTelemetry's convention, and two redundant counters were dropped; the failure tag is
error.type, naming the exception the job actually threw. - Logging is
Microsoft.Extensions.Loggingthroughout. LibLog is gone, and so is theQuartz.Loggingnamespace with it.
Data and serialization
- System.Text.Json serialization ships in the core package. Newtonsoft is opt-in, its types no longer sit in namespaces that read as core's, and the two
Quartz.JsonConfigurationExtensionsthat used to collide are nowSystemTextJson…andNewtonsoft…. - Serializer registries are per-scheduler rather than process-global statics, so two schedulers in one process can serialize different types.
JobDataMapandSchedulerContextstand alone — sealed, implementingIDictionary<string, object?>directly, with the typed accessors as extension members over the dictionary, plusDateOnly,TimeOnly,enumandTryGet<T>accessors and round-trippablePutAsStringformats.[Serializable]survives only on the types a job store blob is made of, so a 3.x database whose blobs were written byBinaryFormatterstill reads while you migrate it to JSON.
Extending Quartz
- An
IJobDetailof your own is finally implementable. The one member no implementation could write —GetJobBuilder()— is replaced byWithJobData(JobDataMap)andClone(), andRAMJobStorehands your type back instead of quietly swapping in Quartz's. (closes #1143) IJobFactoryhands out aJobScoperather than a bare instance, so a job's DI scope has a defined lifetime andIJobWrapperis no longer needed.IThreadPoolis asynchronous —TryRun(Func<ValueTask>)andWaitForAvailableThreads.- The three
*Supportlistener base classes are gone because every notification member is a default interface member now; implementing a listener costs aNameat most. Quartz.SpiisQuartz.ExtensibilityandQuartz.Simplmerged intoQuartz.Impl. String-typed configuration naming the old namespaces still resolves, with a warning — including a storedJOB_CLASS_NAMEwritten by 2.x or 3.x, which now goes through the same fallback.
Fixes worth calling out
Everything is listed under What's Changed; these three change behaviour in a running cluster without saying so.
ResumeAllcould unpause real trigger groups. It deleted the all-groups-paused sentinel row with aLIKE, and the sentinel is_$_ALL_GROUPS_PAUSED_$_— whose four underscores are single-character wildcards, so the statement also matched any paused group name of the same length differing only in those positions. It compares with=now.- Recovered triggers lost their priority.
ClusterRecoverread the dead node's fired triggers through a reader that never populatedFiredTriggerRecord.Priority, so every recovery trigger was created with priority zero and recovered work was acquired last. - A blank calendar name silently killed a trigger. An empty or whitespace-only calendar name was stored as-is and then failed to resolve on the next fire. It is normalized to "no calendar" at every entry point. (fixes #3294)
Not final yet
An alpha is where the surface can still move. Specifically:
- Names are still negotiable. If a member reads badly, or you cannot find something you had, open an issue — that is much cheaper now than after 4.0.0.
- Sealed and internalized types can be reopened. A great many types were sealed or made internal because nothing in the repository needed them open; the guide's appendix indexes every one. If one of them was load-bearing for you, say so and it can go back.
- No compatibility promise between pre-releases. The stored data format is meant to be settled — the schema, the blob graph and the wire contract are what 4.0 intends to ship — but the API may still change between alpha and rc.
- Still open for 4.0: filtering trigger acquisition by job type (#2238, #3282). Five-field Unix cron (#1253) is deferred to 4.1: Unix numbers day-of-week from 0 and Quartz from 1, so accepting a bare five-field expression would be a mis-scheduling trap. 4.0 ships a parse error that names the fix.
Thanks
- @HarnageaGabriel for #3281, the cluster-aware executing-fire-instance API that became
QueryFireInstances, and for #3282, the job-type acquisition filter still in review. - @kaaja-h for #1143, which pinned down exactly why a custom
IJobDetailcould not work — the report is the reasonGetJobBuilderis gone. - @Kuznetsov-Alexey for #1416, which asked for a trigger state that says "running" — and got one that a whole cluster can see.
- @da3dsoul for #2238, a proposal that has been shaping the acquisition SPI for years.
- @jabellard for #814, the question about a document-database job store that turned into the
UseJobStore<T>seam and the SPI how-tos. - Everyone who commented on the 4.0 roadmap (#988) over the years it stayed open.
What's Changed
Generated at release time.
Full Changelog: v3.19.1...v4.0.0-alpha.1