Scheduler Listeners
SchedulerListeners are like ITriggerListeners and IJobListeners, but receive events from the scheduler itself, not necessarily about a specific trigger or job: a job or trigger added or removed, a serious error in the scheduler, the scheduler shutting down, and others.
Caution
Make sure your scheduler listeners never throw an exception (use a try-catch) and that they can handle internal problems. Quartz can get in unpredictable state when it is unable to determine whether required logic in listener was completed successfully when listener notification failed.
The ISchedulerListener Interface
public interface ISchedulerListener
{
string Name => GetType().Name;
ValueTask JobScheduled(IScheduler scheduler, ITrigger trigger, CancellationToken cancellationToken = default);
ValueTask JobUnscheduled(IScheduler scheduler, TriggerKey triggerKey, CancellationToken cancellationToken = default);
ValueTask TriggerFinalized(IScheduler scheduler, ITrigger trigger, CancellationToken cancellationToken = default);
ValueTask TriggersPaused(IScheduler scheduler, string? triggerGroup, CancellationToken cancellationToken = default);
ValueTask TriggersResumed(IScheduler scheduler, string? triggerGroup, CancellationToken cancellationToken = default);
ValueTask JobsPaused(IScheduler scheduler, string? jobGroup, CancellationToken cancellationToken = default);
ValueTask JobsResumed(IScheduler scheduler, string? jobGroup, CancellationToken cancellationToken = default);
ValueTask SchedulerError(IScheduler scheduler, SchedulerErrorContext errorContext, CancellationToken cancellationToken = default);
ValueTask SchedulerShutdown(IScheduler scheduler, CancellationToken cancellationToken = default);
// ...and the rest; every member has a do-nothing default implementation, so implement only what you care about
}
A null group in JobsPaused, JobsResumed, TriggersPaused or TriggersResumed means every group.
Every callback names its scheduler
No scheduler-listener callback runs inside a firing, so every member takes the scheduler as its first argument. One listener instance can then serve several schedulers in one host and know which one it is hearing from:
public sealed class AuditSchedulerListener : ISchedulerListener
{
public ValueTask TriggerPaused(IScheduler scheduler, TriggerKey triggerKey, CancellationToken cancellationToken = default)
{
logger.LogInformation("{SchedulerName} paused {TriggerKey}", scheduler.SchedulerName, triggerKey);
return default;
}
}
The argument is the scheduler itself, so the listener can act on it: pause the trigger, read Status, ask for the job. For identity only, read SchedulerName and SchedulerInstanceId.
Hearing a reschedule
There is no reschedule callback. RescheduleJob raises two, in this order, once the store has replaced the trigger:
| Order | Callback | Receives |
|---|---|---|
| 1 | JobUnscheduled | the old trigger's key |
| 2 | JobScheduled | the new trigger |
- Nothing is raised when the old trigger is not found.
- A start that re-applies an
AddTriggerorScheduleJobregistration to a persistent store reschedules each trigger already stored, so the pair is raised then too. The trigger keeps its run state and its pause (see What a start keeps), and noTriggerPausedis raised for a pause it kept. UpdateTriggerDetailskeeps the fire times and raises nothing.- Only the scheduler that made the call raises them. Other nodes of a cluster hear nothing.
Reporting an error
SchedulerError is raised when something goes seriously wrong: a job that could not be built, a job store that keeps failing, a job that threw. Its SchedulerErrorContext says what went wrong and, where the scheduler knows, for which trigger, job and firing:
public sealed record SchedulerErrorContext
{
public required string Message { get; init; }
public required SchedulerException Exception { get; init; }
public TriggerKey? TriggerKey { get; init; }
public JobKey? JobKey { get; init; }
public string? FireInstanceId { get; init; }
}
The three keys are null when there is nothing to name, such as a scan that never reached a trigger or a store retrying a connection. Every failure inside a firing fills in all three:
public ValueTask SchedulerError(IScheduler scheduler, SchedulerErrorContext errorContext, CancellationToken cancellationToken = default)
{
if (errorContext.TriggerKey is { } triggerKey)
{
return scheduler.PauseTrigger(triggerKey, cancellationToken);
}
logger.LogError(errorContext.Exception, "{Message}", errorContext.Message);
return default;
}
Registering
Any object implementing ISchedulerListener can be registered with the scheduler's ListenerManager.
A scheduler listener is identified by its Name, which defaults to the type's name. Registering a second listener under a taken name replaces the first, so override Name if you register several instances of one type with the same scheduler.
Adding a SchedulerListener:
scheduler.ListenerManager.AddSchedulerListener(mySchedListener);
Removing a SchedulerListener:
scheduler.ListenerManager.RemoveSchedulerListener(mySchedListener.Name);
Register a listener that belongs to the application where the scheduler is configured. The container constructs it, and it is in place before the scheduler starts, so it also hears the starting and started notifications:
builder.AddQuartz(q =>
{
q.AddSchedulerListener<AuditSchedulerListener>();
});
Overloads take an instance you built or a factory over the service provider, like the ones for job and trigger listeners.
