One-Off Job
To run a job once, now or at a chosen time, trigger a stored job on demand or schedule a job and trigger built on the spot.
A job registered ahead of time, triggered on demand
Register the job at startup:
builder.Services.AddQuartz(q =>
{
q.AddJob<AnExampleJob>(j => j
.WithIdentity("name", "group")
.StoreDurably());
});
StoreDurably() keeps a job with no triggers in the store; otherwise it is deleted when it has none.
Trigger it from anywhere with the scheduler. TriggerJob fires it once, immediately, and leaves nothing behind:
public async ValueTask RunNow(IScheduler scheduler, CancellationToken cancellationToken)
{
await scheduler.TriggerJob(new JobKey("name", "group"), cancellationToken: cancellationToken);
}
A JobDataMap passed to it is merged over the job's data for this firing only:
public async ValueTask RunNow(IScheduler scheduler, string customer, CancellationToken cancellationToken)
{
JobDataMap data = new() { { "CustomerId", customer } };
await scheduler.TriggerJob(new JobKey("name", "group"), data, cancellationToken);
}
For a job not registered at startup, use AddJob:
IJobDetail job = JobBuilder.Create<AnExampleJob>()
.WithIdentity("name", "group")
.StoreDurably()
.Build();
await scheduler.AddJob(job, new AddJobOptions { Replace = true }, cancellationToken);
Replaceallows re-registering a taken name; without it the call throwsObjectAlreadyExistsException.StoreNonDurableWhileAwaitingSchedulingstores a non-durable job with no trigger, expecting one. Once a trigger arrives the job is ordinary, deleted when it has no triggers left.
A job and a trigger built on the spot
public async ValueTask ScheduleOnce(IScheduler scheduler, CancellationToken cancellationToken)
{
IJobDetail job = JobBuilder.Create<AnExampleJob>()
.WithIdentity("name", "group")
.Build();
ITrigger trigger = TriggerBuilder.Create()
.WithIdentity("name", "group")
.StartAt(DateTimeOffset.UtcNow.AddMinutes(5))
.Build();
await scheduler.ScheduleJob(job, trigger, cancellationToken: cancellationToken);
}
No StoreDurably(): both are removed once the trigger has fired and has nothing left to do.
A trigger with no schedule fires once, at its start time; StartNow() means as soon as the scheduler gets to it. A bare .WithSimpleSchedule() changes nothing, so add one only to configure it:
ITrigger trigger = TriggerBuilder.Create()
.WithIdentity("name", "group")
.StartNow()
.WithSimpleSchedule(x => x
.WithMisfireInstruction(SimpleTriggerMisfireInstruction.FireNow))
.Build();
Misfire behaviour
A one-shot trigger on the default SmartPolicy resolves to FireNow: a firing missed while the scheduler was down runs when it returns. See SimpleTriggers.
A payload and a time, in one call
For a job with a typed input, pass the job type, the input and the time:
public sealed record SendInvoice(string CustomerId, decimal Amount);
public sealed class SendInvoiceJob : IJob<SendInvoice>
{
public ValueTask Execute(IJobExecutionContext context, SendInvoice input, CancellationToken cancellationToken = default)
{
// input.CustomerId, input.Amount
return default;
}
}
public sealed class Invoicing
{
public async ValueTask Remind(IScheduler scheduler, ILogger logger, SendInvoice invoice, CancellationToken cancellationToken)
{
ScheduledOneOffJob firing = await scheduler.ScheduleJob<SendInvoiceJob, SendInvoice>(
invoice,
TimeSpan.FromDays(7),
// Named, so it can be replaced or cancelled; grouped by the thing it is about, so the
// whole conversation can be cancelled at once. Replacing(name) is the preset for the
// pair, because a firing with no name of its own has nothing to replace.
OneOffJobOptions.Replacing($"invoice-{invoice.CustomerId}") with { Group = invoice.CustomerId },
cancellationToken);
// What was arranged: the trigger's key, and when the store says it will first fire.
logger.LogInformation("Reminder {Trigger} scheduled for {At}", firing.TriggerKey, firing.FirstFireTimeUtc);
// ... and to call it off:
await scheduler.UnscheduleJob(firing.TriggerKey, cancellationToken);
}
}
One overload takes a DateTimeOffset, the other a TimeSpan from now. Both return a ScheduledOneOffJob:
| Member | Meaning |
|---|---|
TriggerKey | the stored firing; cancel it with this, or replace it by scheduling the same name again |
FirstFireTimeUtc | when the store says it will fire; for a kept firing, when that one fires |
Outcome | Created, Replaced or Kept; see Scheduling the same name twice |
Read anything else with GetTrigger.
What is stored:
- One durable job per job type at
SchedulerConstants.ScheduledJobKey<TJob>(), which is(typeof(TJob).Name, SchedulerConstants.ScheduledJobGroup), plus one trigger per call, so there is no job churn however many firings are pending. - The job is stored idempotently on the first call on a scheduler, then remembered: safe from several nodes at once, and one round trip instead of two from the second call.
- It stays after the last firing is cancelled: one row per job type.
OneOffJobOptions holds the TriggerBuilder settings: Name and Group (by default a generated identifier, in a group named after the job type), Description, Priority, ExecutionGroup, MisfireInstruction, and OnConflict. The group is the correlation axis: give one saga, tenant or conversation one group to list, pause or unschedule its firings together.
A stored typed job, run now
From 4.7. Pass TriggerJob the input instead of a JobDataMap. It fires a stored IJob<TInput> now and returns the key of the trigger that fires it:
public async ValueTask RunNow(IScheduler scheduler, ILogger logger, SendInvoice invoice, CancellationToken cancellationToken)
{
// A durable SendInvoiceJob stored under this key, fired now with its input.
TriggerKey firing = await scheduler.TriggerJob(new JobKey("send-invoice", "billing"), invoice, cancellationToken);
// The manual run's trigger: ExecutionHistoryQuery.Trigger finds the run once it has ended.
logger.LogInformation("Invoice run {Trigger} requested", firing);
}
- It is the firing
TriggerJob(jobKey)makes: a one-shot trigger in the default group, recorded as manual, in the job's execution group. - The input is stored as
UsingInputstores it, by the scheduler'sIJobInputSerializer. - Find the run with the key:
ExecutionHistoryQuery.Triggerin the history, orFireInstanceQuery.TriggerNamewhile it runs. - Trimmed or native AOT: declare the input type with
SystemTextJsonSerializerRegistry.AddTypeInfoResolver. - Over
HttpScheduler, pass a string. A structured input is refused before it is sent, because the wire carries it as a job data value. Serialize it to the JSON the host's serializer reads. TriggerJob(jobKey, token)fires the job with no input, asTriggerJob(jobKey, cancellationToken: token)does.
Scheduling the same name twice
OnConflict decides what a call does when a firing is already scheduled under its Name:
public async ValueTask SyncAccount(IScheduler scheduler, ILogger logger, string accountId, CancellationToken cancellationToken)
{
// Idempotent enqueue: "make sure a sync is scheduled". A second call while one is pending
// stores nothing and answers with the pending one.
ScheduledOneOffJob sync = await scheduler.ScheduleJob<SendInvoiceJob, SendInvoice>(
new SendInvoice(accountId, 0m),
TimeSpan.FromMinutes(5),
new OneOffJobOptions { Name = $"sync-{accountId}", OnConflict = TriggerConflict.Keep },
cancellationToken);
if (sync.Outcome == ScheduleOutcome.Kept)
{
logger.LogInformation("A sync was already pending, at {At}", sync.FirstFireTimeUtc);
}
}
OnConflict | A firing already pending under the name | Use it for |
|---|---|---|
Throw (default) | the call throws ObjectAlreadyExistsException | a name that must be new |
Replace | the new firing replaces it; Outcome is Replaced | debounce: only the last call fires |
Keep | it stays, nothing is stored; Outcome is Kept | idempotent enqueue: "make sure this is scheduled" |
KeepEarlier | whichever fires first stays | debounce with a deadline that only moves closer |
- The store decides under its lock. Two calls with one name at once store one firing; one is told
Created, the otherKept. - Pending means a fire time ahead. A paused firing is pending. A firing that has started running is not, so
Keepstores a new one after it. Replace = trueis the older spelling ofOnConflict = TriggerConflict.Replace, andOneOffJobOptions.Replacing(name)setsOnConflict.Replace = truebeside anotherOnConflictthrowsArgumentException.- A kept call raises no
JobScheduled, because nothing was scheduled. - "Unique for a period after the job ran" is not covered: a finished firing leaves nothing to conflict with.
A group default that a cancellation contract has to know about
Group defaults to the job type's name, not TriggerKey.DefaultGroup. Code cancelling with new TriggerKey(id) targets the default group, so it silently finds nothing for firings scheduled here without Group = TriggerKey.DefaultGroup. An integration moving to the one-liner under an existing trigger-key contract must set the group its callers expect.
For a per-tenant execution limit, name the tenant's group at the call site: new OneOffJobOptions { ExecutionGroup = $"tenant:{invoice.CustomerId}" }. ExecutionGroup is stored as written. A job type's [ExecutionGroup] applies when ExecutionGroup is not set, if it has no placeholders; with placeholders the call throws FormatException, because a one-off trigger's job data is its input.
RequestRecovery describes the durable job, not the trigger: the job is marked RequestsRecovery, so a firing cut short by a hard shutdown re-runs when the scheduler returns:
public async ValueTask Remind(IScheduler scheduler, SendInvoice invoice, CancellationToken cancellationToken)
{
await scheduler.ScheduleJob<SendInvoiceJob, SendInvoice>(
invoice,
TimeSpan.FromDays(7),
new OneOffJobOptions { RequestRecovery = true },
cancellationToken);
}
The job is stored once per scheduler instance, so the first call's value wins for the life of the process, like the job's description, durability and type.
A schedule of your own on the same job
Point a recurring trigger of your own at ScheduledJobKey<TJob>(); do not add your own job to the reserved group:
public async ValueTask Nightly(IScheduler scheduler, CancellationToken cancellationToken)
{
// A schedule of its own, pointed at the job the one-liner keeps rather than at a second job
// built here: same job, same payload shape, one more trigger.
ITrigger nightly = TriggerBuilder.Create<SendInvoiceJob>(scheduler.TimeProvider)
.WithIdentity("nightly", "invoicing")
.ForJob(SchedulerConstants.ScheduledJobKey<SendInvoiceJob>())
.WithCronSchedule("0 0 2 * * ?")
.UsingInput(new SendInvoice("all", 0m))
.Build();
await scheduler.ScheduleJob(nightly, cancellationToken: cancellationToken);
}
SchedulerConstants.ScheduledJobGroupis reserved for the one-liner's jobs, but its job is meant to be pointed at.- The key comes from the type, so it is known before anything is scheduled; the job appears on the first one-call overload.
- A store refuses a trigger whose job is missing. If your path can run first, store the job yourself with
AddJobunder the same key andAddJobOptions.Replacing, which is idempotent with the one-liner.
A firing whose time is another firing's completion
The third overload takes the TriggerKey of an earlier firing instead of a time, and stores a continuation: held in TriggerState.Awaiting, never acquired, and released or discarded by the first firing's outcome.
public sealed class InvoiceRun
{
public async ValueTask Send(IScheduler scheduler, ImportRequest request, CancellationToken cancellationToken)
{
ScheduledOneOffJob import = await scheduler.ScheduleJob<DataImportJob, ImportRequest>(
request,
TimeSpan.FromMinutes(5),
cancellationToken: cancellationToken);
// The second firing's time is the first firing's completion, so the overload takes a
// Continuation where the others take a DateTimeOffset or a TimeSpan.
await scheduler.ScheduleJob<ReconcileJob, ImportRequest>(
request,
Continuation.After(import.TriggerKey, ContinuationCondition.OnSuccess),
cancellationToken: cancellationToken);
}
}
Continuation.After(parent)waits for success; pass aContinuationConditionfor others, such asOnAnyOutcomefor a cleanup.- For a floor ("and never before nine"), build the trigger by hand with
StartAfterandStartAt.
Reading input written by an older schema
IJob<TInput> fails a firing whose input is missing. Midway through converting a 3.x job, the store still holds triggers with the payload in flat JobDataMap keys and nothing under QRTZ_JOB_INPUT, so each would throw. Keep the job an IJob while both shapes exist:
public sealed class SendInvoiceCompatJob : IJob
{
public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
{
// A firing scheduled by 4.x carries the whole payload under one key. One scheduled before the
// upgrade carries the flat keys the 3.x job wrote, and there is nothing to read there — which
// is an answer here, where an IJob<SendInvoice> would have failed the firing instead.
if (!context.TryGetInput(out SendInvoice? invoice) || invoice is null)
{
invoice = new SendInvoice(
context.MergedJobDataMap.GetString("CustomerId")!,
context.MergedJobDataMap.Get<decimal>("Amount"));
}
return Send(invoice, cancellationToken);
}
private static ValueTask Send(SendInvoice invoice, CancellationToken cancellationToken) => default;
}
TryGetInput returns false only when the key is absent; a present but unreadable value still throws. Once the old triggers have drained, switch to IJob<TInput>.
Calling off a whole correlation
public async ValueTask<int> CustomerWentAway(IScheduler scheduler, string customerId, CancellationToken cancellationToken)
{
// Every firing scheduled under this customer's group goes in one call: the group the one-liner
// put them in is the handle for calling all of them off, and nothing has to list the keys first.
List<TriggerKey> calledOff = await scheduler.UnscheduleJobs(
GroupMatcher<TriggerKey>.GroupEquals(customerId),
cancellationToken);
// The answer names what went, so "there was nothing left to cancel" is a count, not a guess.
return calledOff.Count;
}
UnscheduleJobs(GroupMatcher<TriggerKey>)removes every trigger in the matching groups and returns the removed keys (empty if none).- The store resolves the group under the lock that empties it, so a firing another node added a moment earlier is removed too.
DeleteJobs(GroupMatcher<JobKey>)does the same for jobs and their triggers. Do not use it to cancel a correlation: the one-liner's durable job is shared by every firing of its type.
A matcher is required
Both throw ArgumentNullException on a null matcher, unlike the pause and resume group forms, which read it as the default group. A mistaken pause can be resumed; a delete cannot.
Scheduling over a firing that is already there
The ScheduleJob overloads taking ScheduleJobOptions replace an existing trigger in one call, inside the store's lock:
// Trigger only - the job it names is already stored.
await scheduler.ScheduleJob(trigger, new ScheduleJobOptions { Replace = true }, cancellationToken);
// Job and trigger together, in one store operation.
await scheduler.ScheduleJob(job, trigger, new ScheduleJobOptions { Replace = true }, cancellationToken);
- They replace
CheckExists→UnscheduleJob→ScheduleJob: three round trips and a race with other nodes. optionshas no default here, orscheduler.ScheduleJob(trigger)would be ambiguous.- A replaced trigger keeps its previous fire time, so
context.PreviousFireTimeUtcsurvives the rewrite. SetPreviousFireTimeUtcon the new trigger to override it.
For the other conflict modes on a trigger you built yourself, use ScheduleTrigger:
public async ValueTask<ScheduleOutcome> ScheduleOnce(IScheduler scheduler, ITrigger trigger, CancellationToken cancellationToken)
{
// Any trigger, not only the one-liner's: the store decides under its lock.
ScheduleTriggerResult result = await scheduler.ScheduleTrigger(trigger, TriggerConflict.KeepEarlier, cancellationToken);
// Created, Replaced or Kept; result.NextFireTimeUtc is when the stored trigger fires.
return result.Outcome;
}
It takes the same four TriggerConflict values and answers with the outcome and the stored trigger's next fire time. Over HTTP it is onConflict on …/triggers/schedule.
