github quartznet/quartznet v4.0.0-alpha.1

pre-release52 minutes ago

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:

Before you start

Four things have to be true before a 3.x application will build and run on 4.0:

  1. The project targets net10.0. There is no netstandard2.0 build and no Full Framework .config support.
  2. The schema migration has been run. Four columns on QRTZ_TRIGGERS and one on QRTZ_FIRED_TRIGGERS were optional in 3.x and are required now, so 4.0 does not probe for them. schema_30_to_40_upgrade_<dialect>.sql folds in everything from 3.17 onward.
  3. The merged packages are dropped from the project file. Quartz.Extensions.DependencyInjection, Quartz.Extensions.Hosting and Quartz.Serialization.SystemTextJson are part of Quartz now. Quartz.Serialization.Json is Quartz.Serialization.Newtonsoft, and Quartz.OpenTracing is dropped in favour of OpenTelemetry.Instrumentation.Quartz.
  4. 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 CalendarIntervalTrigger with PreserveHourOfDayAcrossDaylightSavings steps 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 Quartz package. StdSchedulerFactory, DirectSchedulerFactory, quartz.config file discovery, SchedulerRepository.Instance and DBConnectionManager.Instance are gone. Flat quartz.* 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.
  • QuartzSchedulerBuilder is the DI-free entry point and is the same builder AddQuartz uses, so there is one configuration API rather than two that resemble each other. Its configuration members return the builder's own type, so Create().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 fails Host.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 ValidateOnBuild does not spin one up.

Listings are queries

  • QueryJobs, QueryTriggers, QueryJobGroups, QueryTriggerGroups and QueryCalendarNames take a query record and return a PagedResult<T> with Items, an exact HasMore and an optional TotalCount. A table with a hundred thousand triggers no longer loads in one go. Take defaults to 250; asking for everything is an explicit Take = int.MaxValue.
  • Listings project. JobHeader and TriggerHeader carry 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 than LIKE, so a group literally named 50% matches itself. IsJobGroupPaused asks the store about one group instead of listing every paused group and searching it.
  • Bulk fetch by keyGetJobDetails(keys) and GetTriggers(keys) turn N round trips into one, over ADO.NET and over HTTP alike.
  • The eight removed IScheduler listing 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 the DEFAULT group.

What is running, and what state it is in

  • TriggerState.Executing is reported directly, and with a persistent store it is visible from every node. Blocked narrows to its real meaning: a different trigger of the same [DisallowConcurrentExecution] job is running. (closes #1416)
  • Fire instances are a listing. QueryFireInstances(FireInstanceQuery) returns a PagedResult<FireInstance> — fire instance id, trigger, job, node, state, scheduled and actual fire time, execution group — answered cluster-wide by a persistent store. It replaces GetCurrentlyExecutingJobs, 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. JobInstantiationException carries 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 / TriggersInError fire on every transition into TriggerState.Error, including two in the ADO store that previously reached nothing at all.

Scheduling

  • Misfire instructions are per-family enumsWithMisfireInstruction(SimpleTriggerMisfireInstruction.FireNow) instead of an untyped int and a method name per instruction. The stored numbers are unchanged.
  • TimeOnly and DateOnly replace Quartz's own TimeOfDay, and TimeProvider replaces SystemTime, so faking the clock in a test uses the BCL. The scheduler's clock is injected rather than ambient, and AddQuartz picks up a TimeProvider registered in the container.
  • Builders carry the job type. JobBuilder<TJob> and TriggerBuilder<TJob> let UsingJobData name a job property with an expression instead of a string key, and the nine UsingJobData overloads collapse to one.
  • One way to say each thing — one WithInterval per builder instead of the WithIntervalIn* families, Create as the factory verb everywhere, one family of WithXSchedule extensions, and WithCalendarName where ModifiedByCalendar used 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.
  • TriggerDetailsUpdate edits 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. PreferredNode is a readonly struct with Auto / None / For instead 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.
  • RAMJobStore is sealed and DelegatingJobStore is the supported decorator, alongside DelegatingScheduler. 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, StoredTriggerState and its resolution rule are public in Quartz.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 recordsFiredTriggerRecord, StoredTriggerHeader, DriverDelegateContext, TriggerAcquireResult, SchedulerStateRecord, FiredTriggerQuery — and a typed StoredTriggerState instead 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.
  • ValidateSchema is part of IDriverDelegate, so a delegate that is not a StdAdoDelegate no longer silently skips the check that was asked for. The Supports*Column probes 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.HttpClient drives a remote scheduler: HttpScheduler is an IScheduler that turns each call into an HTTP request. With AddQuartzHttpApi / MapQuartzHttpApi serving 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 RecurrenceTrigger was 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 vocabularyTriggerState instead of a string, PagedResult instead of a paging model of its own, and JobKeyDto / TriggerKeyDto instead of a loose (scheduler, group, name) triplet on sixteen methods.

Observability

  • Every span and measurement attribute is named under quartz., and the ActivitySource and Meter names 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.Logging throughout. LibLog is gone, and so is the Quartz.Logging namespace 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.JsonConfigurationExtensions that used to collide are now SystemTextJson… and Newtonsoft….
  • Serializer registries are per-scheduler rather than process-global statics, so two schedulers in one process can serialize different types.
  • JobDataMap and SchedulerContext stand alone — sealed, implementing IDictionary<string, object?> directly, with the typed accessors as extension members over the dictionary, plus DateOnly, TimeOnly, enum and TryGet<T> accessors and round-trippable PutAsString formats.
  • [Serializable] survives only on the types a job store blob is made of, so a 3.x database whose blobs were written by BinaryFormatter still reads while you migrate it to JSON.

Extending Quartz

  • An IJobDetail of your own is finally implementable. The one member no implementation could write — GetJobBuilder() — is replaced by WithJobData(JobDataMap) and Clone(), and RAMJobStore hands your type back instead of quietly swapping in Quartz's. (closes #1143)
  • IJobFactory hands out a JobScope rather than a bare instance, so a job's DI scope has a defined lifetime and IJobWrapper is no longer needed.
  • IThreadPool is asynchronousTryRun(Func<ValueTask>) and WaitForAvailableThreads.
  • The three *Support listener base classes are gone because every notification member is a default interface member now; implementing a listener costs a Name at most.
  • Quartz.Spi is Quartz.Extensibility and Quartz.Simpl merged into Quartz.Impl. String-typed configuration naming the old namespaces still resolves, with a warning — including a stored JOB_CLASS_NAME written 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.

  • ResumeAll could unpause real trigger groups. It deleted the all-groups-paused sentinel row with a LIKE, 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. ClusterRecover read the dead node's fired triggers through a reader that never populated FiredTriggerRecord.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 IJobDetail could not work — the report is the reason GetJobBuilder is 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

Don't miss a new quartznet release

NewReleases is sending notifications on new releases.