Version compatibility
This documentation relates to Quartz version 4.3 and later.
A delegate job is a lambda whose parameters are what it needs. ScheduleJob adds one with its trigger, and AddJob adds one on its own.
services.AddQuartz(q =>
{
q.ScheduleJob(
"session-cleanup",
static async (ISessionStore sessions, ILogger<SessionCleanup> log, CancellationToken cancellationToken) =>
{
int purged = await sessions.PurgeExpired(cancellationToken);
log.LogInformation("Purged {Count} expired sessions", purged);
},
trigger => trigger.WithCronSchedule("0 0 * * * ?"));
});
services.AddQuartzHostedService();
What each parameter is handed
| Parameter type | Handed |
|---|---|
IJobExecutionContext | the firing |
CancellationToken | the firing's token, the same one context.CancellationToken carries |
IServiceProvider | the firing's DI scope |
| any other type | GetRequiredService from that scope |
- Write every parameter's type. A lambda without them has no delegate type and does not compile.
- Return
Task,ValueTaskor nothing.AddJobandScheduleJobthrowArgumentExceptionfor any other return type,async void, aref,out,in, pointer or ref struct parameter, and a combined delegate. async () => await repo.Count()returnsTask<int>, and is refused. A job has no result. Use a block body, or setIJobExecutionContext.Result.- A missing service fails the firing, not the registration: the run shell reports a
JobExecutionExceptionwhose inner exception is the container's. - A scheduler's own parts fail validation at startup:
IScheduler,ISchedulerFactory, a scheduler's options. TakeIJobExecutionContextand readcontext.Scheduler. It is the rule for a registered job's constructor. - Job data is
context.MergedJobDataMap. It is not applied to properties, since a lambda has none.
Bound at compile time
Quartz's source generator binds a handler written as a lambda or a method group when the project compiles. It intercepts the AddJob or ScheduleJob call and writes code that resolves each parameter and calls the handler. A firing then runs no reflection and allocates nothing for the arguments.
Any other handler is bound by reflection when the job is added. Both paths hand the same arguments and fail with the same exceptions, and both work trimmed and under native AOT.
| The handler | Bound |
|---|---|
| A lambda or a method group, C# 11 or later | At compile time |
A Delegate variable or parameter, a cast, new Func<…>(…) | By reflection |
A parameter type another file cannot name: a type parameter, a private or file type, a tuple | By reflection |
A shape Quartz refuses: a result, async void, ref, a ref struct | Refused by the call |
| Any, in C# 10 | By reflection |
Any, with <DisableQuartzAnalyzers>true</DisableQuartzAnalyzers> | By reflection |
The package's build file adds Quartz.Generated to InterceptorsNamespaces. A project that removes it binds by reflection.
Adding one
| Call | The job | Its trigger |
|---|---|---|
ScheduleJob(name, handler, trigger) | takes its trigger's identity; not durable | named name unless trigger renames it |
AddJob(name, handler, configure) | name in the default group, unless configure sets an identity; durable | none: add one with AddTrigger, or fire it with TriggerJob |
Each has a twin whose callback also takes the IServiceProvider, for configuration read from services.
services.AddQuartz(q =>
{
// Durable, and fired only by a trigger of its own or by TriggerJob.
q.AddJob("send-digest", static (IEmailSender email, CancellationToken cancellationToken) =>
email.SendDigest(cancellationToken));
q.AddTrigger(trigger => trigger
.ForJob("send-digest")
.WithCronSchedule("0 0 7 ? * MON-FRI"));
});
A one-off firing is the named job fired with data of its own:
// A one-off firing of the named job, carrying data of its own.
await scheduler.TriggerJob(
new JobKey("send-digest"),
new JobDataMap { ["recipient"] = "[email protected]" },
cancellationToken);
services.AddQuartz(q =>
{
q.AddJob("send-digest", static (IJobExecutionContext context, ILogger<SessionCleanup> log) =>
{
string? recipient = context.MergedJobDataMap.GetString("recipient");
log.LogInformation("Digest for {Recipient}, fired by {Trigger}", recipient, context.Trigger.Key);
});
});
Persistence and clusters
- Every delegate job is stored as
Quartz.Impl.DelegateJob, Quartz. The name resolves on every node, so the job persists and clusters like any other. ItsDescriptiondefaults toDelegate job '<name>'. - The job key is the identity. The handler is code and is not stored. A firing finds it by the job's key, on the scheduler that fires it.
- Register the job on every node that runs the scheduler. A node without it fails the firing with a
JobExecutionExceptionnaming the key and the scheduler, so the trigger's retry policy andSchedulerErrorapply. - Two handlers under one key on one scheduler are refused when the scheduler starts.
- Named schedulers keep their own handlers. The same key on two schedulers is two jobs.
- An
IsJobTypeAllowedallow-list on the HTTP API or the dashboard must allowQuartz.Impl.DelegateJob, Quartzfor a caller to add a delegate job by type name. Such a job runs only if a handler is registered under its key.
What composes
| Feature | With a delegate job |
|---|---|
[DisallowConcurrentExecution] | .DisallowConcurrentExecution() in AddJob's configure; both stores enforce it |
[PersistJobDataAfterExecution] | .PersistJobDataAfterExecution() in AddJob's configure |
[JobTimeout] | AddJobTimeout(defaultTimeout); there is no attribute to put on a lambda |
| Retry policy, execution group, preferred node, calendar | on the trigger, as for any job |
| Middleware, listeners, execution history, metrics | unchanged; the job type they report is DelegateJob |
ConfigureJobScope | runs before the handler's services are resolved |
services.AddQuartz(q =>
{
// DelegateJob carries no [JobTimeout], so the scheduler-wide default bounds it.
q.AddJobTimeout(TimeSpan.FromMinutes(5));
q.AddJob(
"reindex",
static async (ISessionStore sessions, CancellationToken cancellationToken) =>
{
await sessions.PurgeExpired(cancellationToken);
},
job => job
.WithIdentity("reindex", "maintenance")
.WithDescription("Rebuilds the session index")
// The attribute's builder form: every delegate job shares one type.
.DisallowConcurrentExecution());
q.AddTrigger(trigger => trigger
.ForJob("reindex", "maintenance")
.WithCronSchedule("0 0/15 * * * ?")
.WithRetryPolicy(RetryPolicy.Exponential(3, TimeSpan.FromSeconds(30))));
});
When to write an IJob class instead
- The job needs a typed input:
IJob<TInput>andScheduleJob<TJob, TInput>take a class. - It needs a
[JobTimeout]of its own, rather than the scheduler-wide default. - It should be tested, reused or found by name in the code base.
- Job data should arrive as properties.
- Not every node that runs the scheduler registers the same jobs. A class resolves by its type name; a delegate job needs its handler registered.
Related
- Using Quartz:
AddJob<T>,AddTrigger<T>andScheduleJob<T> - Declaring Jobs with Attributes: a class declared with
[QuartzJob]and[CronTrigger]or[SimpleTrigger] - One-Off Job: many short-lived firings of one job
