Quartz.NETQuartz.NET
Home
Features
Blog
Discussions
NuGet
GitHub
Home
Features
Blog
Discussions
NuGet
GitHub
  • Getting Started

    • Overview
    • Quartz 4 Quick Start
    • Tutorial
      • Using Quartz
      • Jobs And Triggers
      • More About Jobs & JobDetails
      • Job Data
      • More About Triggers
      • Querying Jobs and Triggers
      • Simple Triggers
      • Cron Triggers
      • RecurrenceTrigger
      • Time and TimeProvider
      • Trigger and Job Listeners
      • Scheduler Listeners
      • Job Execution Middleware
      • Job Stores
      • Configuration, Resource Usage and Building a Scheduler
      • Building a Scheduler Without a Host
      • Clustering
      • Execution Groups
      • Node Affinity (Preferred Node)
      • Testing
    • Configuration Reference
    • JSON Configuration
    • Cron Expression Reference
    • Multi-Tenancy
    • Frequently Asked Questions
    • Best Practices
    • Before You Go Live
    • Operating a Cluster
    • Log Events
    • Tenancy Patterns
    • Database Schema
    • Database Schema Changes
    • Migration Guide
    • Troubleshooting
    • API Documentation
  • How To's
    • One-Off Job
    • Rescheduling Jobs
    • Retrying Failed Jobs
    • Multiple Triggers
    • Job Template
    • Running Quartz under Aspire
    • Quartz.NET with Wolverine
    • Embedding Quartz in a Library
    • Running under an External Leader Election
    • Publishing Trimmed and Native AOT
    • Extending Quartz: what is open, what is closed, and how to ask
    • A Job Store of Your Own
    • A Driver Delegate for a New Database
    • Persisting a Custom Trigger Type
    • A Lock Handler of Your Own
  • Packages

    • Quartz Core Additions

      • Jobs
      • Serialization (System.Text.Json)
      • JSON Serialization
      • Plugins
    • Integrations

      • Aspire Integration
      • ASP.NET Core Integration
      • HTTP API
      • HTTP Client
      • Dashboard
      • Hosted Services Integration
      • Microsoft DI Integration
      • Multiple Schedulers with Microsoft DI
      • Observability
      • Redis Lock Handler
      • TimeZoneConverter Integration
    • 3rd Party Plugins for Quartz
  • Quartz 3.x

    • Getting Started

      • Quartz 3 Quick Start
      • Tutorial
        • Using Quartz
        • Library Overview
        • Jobs And Triggers
        • More About Jobs
        • More About Triggers
        • Execution Groups
        • Node Affinity (Preferred Node)
        • Simple Triggers
        • Cron Triggers
        • RecurrenceTrigger
        • Trigger and Job Listeners
        • Scheduler Listeners
        • Job Stores
        • Tuning the Scheduler
        • Configuration, Resource Usage and SchedulerFactory
        • Advanced (Enterprise) Features
      • Configuration Reference
      • JSON Configuration
      • Multi-Tenancy
      • Frequently Asked Questions
      • Best Practices
      • Tenancy Patterns
      • Troubleshooting
      • API Documentation
      • Database Schema
      • Database Schema Changes
      • Migration Guide
      • Miscellaneous Features
    • How To's

      • One-Off Job
      • Multiple Triggers
      • Job Template
      • Using the CronTrigger
      • Rescheduling Jobs
    • Packages

      • Quartz Core Additions

        • Dashboard
        • Jobs
        • Serialization (System.Text.Json)
        • Serialization (Newtonsoft Json.NET)
        • Plugins
      • Integrations

        • ASP.NET Core Integration
        • Hosted Services Integration
        • Microsoft DI Integration
        • Multiple Schedulers with Microsoft DI
        • OpenTelemetry Integration
        • OpenTracing Integration
        • Redis Lock Handler
        • TimeZoneConverter Integration
      • 3rd Party Plugins for Quartz
  • Old Releases

    • Quartz 2.x
      • Quartz 2 Quick Start
      • Tutorial
        • Lesson 1: Using Quartz
        • Lesson 2: Jobs And Triggers
        • Lesson 3: More About Jobs & JobDetails
        • Lesson 4: More About Triggers
        • Lesson 5: SimpleTrigger
        • Lesson 6: CronTrigger
        • Lesson 7: TriggerListeners and JobListeners
        • Lesson 8: SchedulerListeners
        • Lesson 9: JobStores
        • Lesson 10: Configuration, Resource Usage and SchedulerFactory
        • Lesson 11: Advanced (Enterprise) Features
        • Lesson 12: Miscellaneous Features of Quartz
        • CronTrigger Tutorial
      • Configuration Reference
      • Migration Guide
      • API Documentation
    • Quartz 1.x
      • Tutorial
        • Lesson 1: Using Quartz
        • Lesson 2: Jobs And Triggers
        • Lesson 3: More About Jobs & JobDetails
        • Lesson 4: More About Triggers
        • Lesson 5: SimpleTrigger
        • Lesson 6: CronTrigger
        • Lesson 7: TriggerListeners and JobListeners
        • Lesson 8: SchedulerListeners
        • Lesson 9: JobStores
        • Lesson 10: Configuration, Resource Usage and SchedulerFactory
        • Lesson 11: Advanced (Enterprise) Features
        • Lesson 12: Miscellaneous Features of Quartz
      • API Documentation
  • License

Quartz.NET with Wolverine

Wolverine has a cron of its own since 6.34, so this page is no longer about a gap.

The request for one, JasperFx/wolverine#1403, was closed the day it was opened with "We're not doing this, ever. Maybe there'll be a move to integrate Quartz.net or Hangfire with Wolverine, but it's not something I'm interested in having to support." JasperFx/wolverine#4307, merged on 5 September 2026 and shipped in 6.34.0 two days later, is the other side of that position rather than a reversal of it. opts.Schedules.ScheduleRecurring publishes a message on a cron expression, and the parser behind it is Cronos, taken into the core package as — in the pull request's own framing — "a parser, not a scheduling engine; MIT, zero transitive deps, correct DST". What shipped is a cron expression riding the scheduled-message machinery Wolverine already had. It is deliberately not a scheduler, and the difference is the subject of this page.

Nothing has shipped between the two libraries. There is no WolverineFx.Quartz package and no Quartz.Wolverine package. A first-class integration was on Wolverine 6's list — its master issue named "Scheduler integrations — Quartz.Net + TickerQ" among the release's goals, its release punchlist recorded that the maintainer "wants involvement before this lands", and the Critter Stack roadmap post of 24 July 2026 said "It's quite possible that Wolverine gets first class documentation and integration for Quartz.Net and TickerQ first" — but those issues are closed and no package has appeared. So this is a recipe, not an announcement, and it is written against Wolverine 6.35.0. The one hook such an integration would need is already there: "Sending Raw Message Data" exists, in its own words, for "integrating scheduling libraries like Quartz.NET or Hangfire where you might be persisting a byte[] for a message to be sent via Wolverine at a certain time", and Deferring a serialized envelope below is that hook used.

A working copy of all of this

src/Quartz.Examples.Wolverine in the Quartz.NET repository is this page as one console application that builds and runs. It is in the solution, so a call on this page that stops compiling fails the build, and every C# block below is checked against it line for line. dotnet run --project src/Quartz.Examples.Wolverine -- --smoke exercises all seven parts against the in-memory store and exits non-zero if any of them stops working; the WolverineSmoke build target runs exactly that on every pull request, on all three operating systems, with no database involved.

Which library should own the schedule

Before wiring anything together it is worth being clear about which runtime should hold the schedule. Most of what a bus calls "scheduling" is not what a scheduler does — and since 6.34 the honest answer is "it depends", where for years it was "Quartz, because there is nothing else".

A transport's delay is a property of one message. Azure Service Bus says so outright: "Because the feature is anchored on individual messages and messages can only be enqueued once, Service Bus doesn't support recurring schedules for messages" (message sequencing). Amazon SQS caps delayed delivery at 15 minutes and tells you to reach for EventBridge Scheduler beyond that. RabbitMQ's delayed-message-exchange plugin, which several buses lean on, keeps its schedule in a single unreplicated table, is documented as being for "a number of seconds, minutes, or hours — a day or two at most", and is no longer maintained: Mnesia was removed in RabbitMQ 4.3.0 and took the plugin with it.

A recurrence is not a message. It is a rule that outlives every message it produces, and it brings a tail of decisions with it: which time zone the expression is read in, what happens to a firing the process was down for, which node in a cluster owns it, and how it is cancelled after the fact. NServiceBus built a scheduler, ran it for years, and then removed it, publishing an unusually candid list of why: the schedule was not durable across a restart, tasks "cannot be canceled or modified after creation", an interval could be specified but not an execution time, and on a scaled-out endpoint a task could be dequeued by an instance that had never created it, so it was "not executed but also not rescheduled". Their conclusion was to deprecate the API "in favor of options like sagas and production-grade schedulers such as Hangfire, Quartz, and FluentScheduler". Rebus never offered recurrence at all, consistent with its self-description as a "message bus without smarts", and Brighter defines a scheduler abstraction whose whole surface is "at this time" and "after this delay", with cron left to whichever backend it is pointed at — Quartz being one.

Two buses went the other way. MassTransit had relied on Quartz for recurring messages for years — and it still ships MassTransit.Quartz — before growing a cron parser of its own inside its Job Service in 2024, and it now tells users that "Quartz.NET or Hangfire are NOT required". Wolverine is the second, and its version is narrower on purpose: a cron expression deciding when an occurrence is published, over the delivery, durability and replay it already had.

Here is the whole of it side by side. Nothing in the right-hand column is a criticism of the left: a bus that grew a cron did not set out to grow a scheduler, and several of these rows are decisions Wolverine's feature declines to make on purpose.

Wolverine opts.SchedulesA Quartz trigger
Cron grammarCronos: five fields, or six with a leading seconds fieldQuartz: six fields, or seven with a trailing year, plus L, W, #, H hashing, wrapping ranges and a five-field CronFormat.Unix mode
Time zoneper schedule; UTC unless one is suppliedper trigger, with InTimeZone
Fastest cadenceevery five seconds; anything quicker is refused at the registration call siteno floor
A firing the process was down forskipped, alwaysa misfire instruction per trigger: DoNothing skips it, FireAndProceed fires one catch-up, IgnoreMisfires fires all of them
Dates it must not fire onnothing; the handler returns earlya calendar on the trigger: HolidayCalendar, CronCalendar, DailyCalendar, WeeklyCalendar, AnnualCalendar, MonthlyCalendar
Where the schedule livesin code, at UseWolverine; the set is whatever the process was compiled within the job store; added, rescheduled and deleted while the host is up, by any node
Pause and resumeIRecurringScheduleControl.PauseAsync/ResumeAsync/QueryAsync, durable where the store has the tracking table; a resume never back-fillsPauseTrigger, PauseTriggerGroups, PauseAll and their resume halves, with the misfire instruction deciding what the paused window did
One firing, cancelled or movedcancel the pre-scheduled envelopeRescheduleJob, UnscheduleJob, or UnscheduleJobs over a group matcher — a set operation rather than one call per handle
Which node runs itone SingularAgent per cluster, re-assigned on failoverthe store's own trigger lock, so a scheduler on every node still fires each trigger once; PreferredNode pins one where the work is node-specific
The same occurrence twicea deterministic deduplication id, {name}:{occurrenceUtc:O}, collapsed at consumptiontrigger acquisition is the lock, so a firing is exclusive before the job runs rather than after
Payloadwhatever the registered factory builds from the occurrence timea JobDataMap, or a typed input through UsingInput — persisted with the trigger and changeable between firings
Overlapping runsan occurrence is a message like any other[DisallowConcurrentExecution] on the job
Failurethe message's own retries and dead-letter queuea retry policy on the trigger, plus JobExecutionException's refire and unschedule options
Ordering when several are due at oncethe receiving endpoint's own concurrencyPriority on the trigger; MaxBatchSize and a fire-ahead window on the scheduler
Watching itthe recurring-schedule header on each occurrence, surfaced as a wolverine.schedule.name activity tagGetCurrentlyExecutingJobs and Interrupt, the HTTP API and the dashboard
Durabilitythe message store's inbox, plus a wolverine_recurring_messages row per scheduleany supported database, or in-memory

Be fair about what that means. For "publish message X every weekday at 03:00, and skip whatever the process was down for", Wolverine's own schedule is now the right answer: three lines inside UseWolverine, no second runtime, and the occurrence rides the outbox the application already trusts. Adding Quartz for that buys a set of tables nobody asked for. The right-hand column is what you are paying for when you do add it: "Wolverine's own schedules" and "When the schedule still belongs in Quartz" below are the two halves of that choice, in code.

Setting the two up

Both runtimes are ordinary hosted services in one host. Wolverine goes first, so that its runtime is started before anything that might publish into it:

builder.UseWolverine(opts =>
{
    // Handlers live in this assembly. Setting it explicitly rather than letting Wolverine walk the
    // stack is what keeps discovery working under a test runner and under a second host in the same
    // process (JasperFx/wolverine#3776, #3778).
    opts.ApplicationAssembly = typeof(OrderPlaced).Assembly;

    // Part 3 sends raw bytes to an endpoint by name, and a name is the whole of what it can address —
    // there is no live message for Wolverine to route on. A local queue keeps that free of a broker.
    opts.PublishMessage<ArchiveOrders>().ToLocalQueue(Part3RawMessageData.EndpointName);

    // Part 7: Wolverine's own recurring schedule, registered here rather than inside AddQuartz because
    // it is Wolverine's. Since 6.34 this is what most of part 1 would otherwise be reaching for.
    Part7WolverineSchedules.Register(opts, options.ExpiryCron);

    if (options.HasDatabase)
    {
        // The outbox, the inbox and the node table part 5's agent needs. Quartz's own tables go in the
        // same database, because part 6's single transaction cannot span two servers.
        opts.PersistMessagesWithPostgresql(options.PostgresConnectionString!);
    }
});

WolverineFx.RuntimeCompilation is not optional in Wolverine 6: the core package no longer ships Roslyn, and a host left in the default TypeLoadMode.Dynamic throws at startup with "no IAssemblyGenerator (Roslyn) is registered" unless either that package is referenced or handlers were pre-generated with codegen write.

Quartz is registered the way it always is. The in-memory store is the fallback, so UseInMemoryStore() would only restate the default; the persistent branch is what the last two sections need:

builder.Services.AddQuartz(q =>
{
    Part1RecurringPublishing.Register(q, options.ReconciliationCron);
    Part4TunedLatency.Register(q);

    if (options.HasDatabase)
    {
        q.UsePersistentStore(store =>
        {
            store.UsePostgres(options.PostgresConnectionString!);
            store.UseSystemTextJsonSerializer();

            // Development convenience. A production account is usually right not to hold DDL rights;
            // database/migrations/ is what moves a real schema forward.
            store.ProvisionSchema();

            // Part 6 throws without this, rather than silently scheduling outside the caller's
            // transaction.
            store.ConfigureStore(o => o.AcceptEnlistedTransactions = true);
        });
    }

    // Nothing else: the in-memory store is what a scheduler falls back to, so UseInMemoryStore() would
    // only restate the default.
});

Wolverine's own schedules

The registration goes inside UseWolverine rather than inside AddQuartz, and that placement is the whole point: the schedule is Wolverine's, and the scheduler never learns it exists.

public static void Register(WolverineOptions opts, string cron)
{
    // Cronos' grammar, not Quartz's. The zone is the schedule's own, so a deployment that means
    // "03:15 local" says so here rather than hoping the host agrees — the same decision part 1
    // makes with InTimeZone, and one of the few this feature and a Quartz trigger both let you make.
    CronSchedule schedule = new(cron, TimeZoneInfo.Utc);

    // The factory is handed the occurrence time, which is this feature's answer to reading
    // context.ScheduledFireTimeUtc rather than the clock: the message describes the window the
    // schedule says it is for. Without a name the schedule is named for its message type, and
    // ScheduleRecurring<T>(cron) is the whole registration for a message with a parameterless
    // constructor.
    opts.Schedules.ScheduleRecurring(ScheduleName, schedule, occurrence => new ExpireUnpaidOrders(occurrence));
}

What that wires up is one SingularAgent per cluster keeping the next occurrence of every schedule pre-scheduled as an ordinary scheduled message, so delivery, durability and replay stay the machinery Wolverine already had. Every occurrence carries a deterministic deduplication id, {name}:{occurrenceUtc:O}, so an agent failover that re-publishes one collapses it at consumption rather than running it twice. With a relational message store, a wolverine_recurring_messages table records which schedule owns which pending envelope, the agent periodically confirms that envelope is still sitting in the inbox, and a successor agent adopts it instead of publishing a second one.

Three things are worth knowing before relying on it, all of them documented rather than discovered:

  • Nothing faster than every five seconds. Durable scheduled messages replay on DurabilitySettings.ScheduledJobPollingTime, five seconds by default, so a quicker cadence is refused at the registration call site rather than accepted and delivered late. The example's smoke run gives this schedule */5 * * * * * for that reason — part 1's Quartz trigger fires every two seconds under --smoke, and Wolverine would not take that expression.
  • No message store is a supported mode, with a startup warning. The schedules run on the in-memory scheduled model: an occurrence inside a restart window is lost and there is no store-backed deduplication. DurabilityMode.Serverless and DurabilityMode.MediatorOnly are different — they run no agents at all, so a host in either mode with a schedule registered refuses to start rather than accepting a schedule that would silently never fire.
  • A missed occurrence is skipped, never back-filled, including the window a PauseAsync / ResumeAsync pair covers. That is the largest single difference from a Quartz trigger, and it is what the next section is about.

When the schedule still belongs in Quartz

The right-hand column of the table above comes down to four things. A firing the process was down for needs a decided outcome rather than a fixed one; the schedule has to be addable, movable and removable while the host is up; there are dates it must not fire on; or it must not overlap itself. Any of those, and the schedule is a Quartz trigger that publishes into Wolverine — a job like any other. Take IMessageBus in the constructor (Quartz resolves the job from a fresh scope per firing, so a scoped IMessageBus is exactly right) and publish:

public sealed class ReconciliationJob : IJob<ReconciliationWindow>
{
    private readonly IMessageBus bus;
    private readonly ILogger<ReconciliationJob> logger;

    public ReconciliationJob(IMessageBus bus, ILogger<ReconciliationJob> logger)
    {
        this.bus = bus;
        this.logger = logger;
    }

    public async ValueTask Execute(
        IJobExecutionContext context,
        ReconciliationWindow input,
        CancellationToken cancellationToken = default)
    {
        // The scheduler's own clock, not DateTimeOffset.UtcNow: a trigger that misfired and is firing
        // late still reports the time it was scheduled for, which is the window the run is about.
        DateTimeOffset to = context.ScheduledFireTimeUtc ?? context.FireTimeUtc;

        RunReconciliation message = new(to - input.Length, to);
        await bus.PublishAsync(message);

        logger.LogInformation("Published {Message} for the window ending {To:O}", nameof(RunReconciliation), to);
    }
}

IJob<TInput> is the typed-input form: the payload arrives as a parameter rather than as a JobDataMap lookup. Register it with a cron trigger and UsingInput:

q.ScheduleJob<ReconciliationJob>(trigger => trigger
    .WithIdentity("reconciliation", "recurring")
    .WithCronSchedule(cron, x => x
        // The expression is read in this zone, so a deployment that means "03:00 local" says
        // so here rather than hoping the host agrees.
        .InTimeZone(TimeZoneInfo.Utc)
        // What happens when the process was down at 03:00. DoNothing skips to the next
        // firing, which is what part 7's schedule does and all it does; FireAndProceed
        // publishes one catch-up message, which is the choice Wolverine's own schedules do
        // not offer.
        .WithMisfireInstruction(CronTriggerMisfireInstruction.DoNothing))
    .UsingInput(new ReconciliationWindow(TimeSpan.FromDays(1))));

Read context.ScheduledFireTimeUtc rather than the clock. A trigger firing late after a misfire still reports the time it was scheduled for, and that is the window the run is about — the same reading of the schedule that the occurrence-time parameter gives a Wolverine schedule's message factory.

Scheduling one firing from a handler

A Wolverine handler can take IScheduler as a parameter and arrange a single future firing in one call. OneOffJobOptions.Group is the interesting argument: it sets the trigger's group, and the group is a correlation axis — everything scheduled for one order, one saga or one tenant shares it.

public static class OrderPlacedHandler
{
    public static async Task Handle(OrderPlaced message, IScheduler scheduler, CancellationToken cancellationToken)
    {
        ScheduledOneOffJob scheduled = await scheduler.ScheduleJob<PaymentReminderJob, PaymentReminder>(
            new PaymentReminder(message.OrderId, message.Amount),
            ExampleOptions.Current.ReminderDelay,
            new OneOffJobOptions { Group = OrderGroup.For(message.OrderId) },
            cancellationToken);

        // The call answers with the trigger's key and the time the store says it will first fire, so
        // "scheduled for" is what will happen rather than what was asked for.
        Ledger.Record(Events.ReminderScheduled, $"{scheduled.TriggerKey} at {scheduled.FirstFireTimeUtc:u}");
    }
}

The job key is not affected by Group. One durable job detail is stored per job type, under the QRTZ_SCHEDULED group, and each call adds a trigger to it; the group you pass names the trigger.

Cancelling by correlation

Because the group is part of the trigger's identity, withdrawing everything arranged for one order is a single store operation, with the matcher evaluated where the triggers are:

public static class OrderPaidHandler
{
    public static async Task Handle(OrderPaid message, IScheduler scheduler, CancellationToken cancellationToken)
    {
        // The whole cancellation, in one store operation. The matcher is evaluated where the triggers
        // are, so no key list round-trips through this process and there is no window in which a
        // trigger listed a moment ago fires before it can be removed. What comes back is the keys that
        // were actually withdrawn, which is how the caller learns whether it beat the firing.
        List<TriggerKey> cancelled = await scheduler.UnscheduleJobs(
            GroupMatcher<TriggerKey>.GroupEquals(OrderGroup.For(message.OrderId)),
            cancellationToken);

        Ledger.Record(Events.RemindersCancelled, $"{cancelled.Count} for {message.OrderId}");
    }
}

This is worth comparing honestly with the alternatives, because "Quartz can cancel and buses cannot" would be wrong. Azure Service Bus hands back a sequence number and takes it back through CancelScheduledMessageAsync; MassTransit gives you a TokenId and a CancelScheduledMessage contract; Hangfire returns a job id for BackgroundJob.Delete. What none of them offers is a set operation. Each cancels exactly one schedule per call, against a handle the application had to keep. Quartz's schedule identity is a two-part key, and the scheduler exposes group matchers over it — GetTriggerKeys, UnscheduleJobs, PauseTriggerGroups, DeleteJobs — so "everything this tenant owns" is a query rather than a list you were responsible for not losing. NServiceBus saga timeouts sit at the other end: they cannot be cancelled at all, and the documented approach is to let the timeout arrive and be ignored because the saga is gone.

Deferring a serialized envelope

The section of Wolverine's documentation that names Quartz teaches SendRawMessageAsync, which takes a byte[] rather than a message. What it does not show is how to produce the bytes, or where to keep them; WolverineOptions.DefaultSerializer.WriteMessage(message) is the missing line, and a typed job input is the place:

public static async ValueTask<TriggerKey> ScheduleSend<TMessage>(
    IScheduler scheduler,
    IWolverineRuntime runtime,
    TMessage message,
    TimeSpan delay,
    CancellationToken cancellationToken = default) where TMessage : notnull
{
    // Serialized here, at the moment the decision was made, rather than at fire time.
    DeferredEnvelope envelope = new(
        EndpointName,
        typeof(TMessage).ToMessageTypeName(),
        runtime.Options.DefaultSerializer.WriteMessage(message));

    ScheduledOneOffJob scheduled = await scheduler.ScheduleJob<DeferredEnvelopeJob, DeferredEnvelope>(
        envelope,
        delay,
        new OneOffJobOptions { Group = "deferred-envelopes" },
        cancellationToken);

    return scheduled.TriggerKey;
}

At fire time the job hands the stored bytes back to Wolverine:

public async ValueTask Execute(
    IJobExecutionContext context,
    DeferredEnvelope input,
    CancellationToken cancellationToken = default)
{
    IDestinationEndpoint endpoint = bus.EndpointFor(input.EndpointName);

    await endpoint.SendRawMessageAsync(input.Data, configure: envelope =>
    {
        // The stored name rather than SetMessageType<T>(): the type this envelope describes is
        // whatever was serialized, which this job has no static knowledge of.
        envelope.MessageType = input.MessageTypeName;

        // Setting Destination is not optional, and Wolverine 6.35.0 does not do it for you.
        // DestinationEndpoint.SendRawMessageAsync assigns Sender but leaves Destination null, and
        // Executor.ExecuteAsync logs both success and failure through envelope.Destination!, so a
        // raw message that is handled perfectly still ends the pipeline with a
        // NullReferenceException out of the logging call. One line here avoids it.
        envelope.Destination = endpoint.Uri;
    });
}

Two things are load-bearing there. Envelope.MessageType is set from the stored name rather than from SetMessageType<T>(), because the job has no static knowledge of what was serialized; typeof(T).ToMessageTypeName() on the storing side honours a [MessageIdentity] alias where a raw FullName would not. And Envelope.Destination has to be set by hand: as of Wolverine 6.35.0, SendRawMessageAsync assigns Sender but leaves Destination null, while Executor.ExecuteAsync logs both success and failure through envelope.Destination! — so a raw message that is handled perfectly still ends its pipeline with a NullReferenceException out of the logging call. Take that one line out of the example and the smoke run still reports the message delivered, with a NullReferenceException from Executor.ExecuteAsync logged beside it.

Why bother, when the previous section publishes a live object instead? Because the envelope is serialized at the moment the decision was made. A payload stored as bytes is not re-derived from application state that has since moved on, and the message contract can change under it without the stored firing changing meaning. It is the outbox argument, applied to a schedule.

What the latency settings actually do

Reading Quartz's 30-second IdleWaitTime beside Wolverine's 5-second ScheduledJobPollingTime invites the conclusion that Quartz is six times slower to deliver a due message. That is not what the numbers mean.

QuartzSchedulerThread acquires triggers due within the next IdleWaitTime, not triggers due now, and having acquired one it waits out the exact fire time rather than sleeping the interval. More importantly, every in-process mutation — ScheduleJob, AddTrigger, RescheduleJob, DeleteJob — signals the scheduling loop, which releases the wait immediately. A trigger scheduled from a Wolverine handler through this process's own IScheduler therefore does not wait for a sweep at all.

IdleWaitTime bounds the discovery of work this node did not learn about in process: a trigger another node wrote to the shared database, or one recovered from a node that died. It is a cross-node pickup bound and the look-ahead horizon of a single acquisition. Lowering it does not make a locally scheduled job fire sooner, and the one place it genuinely shows is the last section on this page.

With that said, the three settings that are worth touching in front of a message bus:

q.ConfigureScheduler(options =>
{
    // Default 30 s. Only affects how quickly this node notices triggers it did not schedule
    // itself, so it is a clustering setting, not a latency setting.
    options.IdleWaitTime = TimeSpan.FromSeconds(10);

    // Default 1. Must not exceed ThreadPoolOptions.MaxConcurrency, which defaults to 10.
    options.MaxBatchSize = 10;

    // Default TimeSpan.Zero. Without this, MaxBatchSize above changes nothing for triggers
    // that are due milliseconds apart rather than at the same instant.
    options.BatchTriggerAcquisitionFireAheadTimeWindow = TimeSpan.FromMilliseconds(500);
});

MaxBatchSize and BatchTriggerAcquisitionFireAheadTimeWindow are one setting in two halves. With the default window of TimeSpan.Zero only triggers due at the same instant batch together, so raising MaxBatchSize alone leaves the effective batch at one for any schedule spread over time. Set the window to the spread you are willing to fire early by. MaxBatchSize must not exceed the thread pool's MaxConcurrency, and IdleWaitTime has a floor of one second.

Letting Wolverine start the scheduler

AutoStart = false leaves the scheduler built, initialized and bound but in SchedulerStatus.Created. Everything that reads a scheduler still sees it; nothing fires until something calls Start. Shutdown is unaffected — the hosted service stops every scheduler it created, started or not.

builder.Services.AddQuartzHostedService(hosted =>
{
    hosted.AutoStart = false;
    hosted.WaitForJobsToComplete = true;
});

Which "something" presses start depends on whether Wolverine has a message store, and here the tidy answer and the true one differ.

Without persistence, Wolverine runs no agents at all. WolverineRuntime.startAgentsAsync opens with if (Storage is NullMessageStore) { ...; return; }, so the node agent controller is never built and the IAgentFamily registrations in the container are never read. AddSingularAgent<T>() would compile, register, and silently never start. The faithful form is an ordinary IHostedService registered after UseWolverine, since hosted services start in registration order:

public sealed class SchedulerStarter : IHostedService
{
    private readonly ISchedulerFactory schedulerFactory;
    private readonly ILogger<SchedulerStarter> logger;

    public SchedulerStarter(ISchedulerFactory schedulerFactory, ILogger<SchedulerStarter> logger)
    {
        this.schedulerFactory = schedulerFactory;
        this.logger = logger;
    }

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        IScheduler scheduler = await schedulerFactory.GetScheduler(cancellationToken);
        await scheduler.Start(cancellationToken);

        logger.LogInformation("Scheduler '{Name}' started after the Wolverine runtime", scheduler.SchedulerName);
        Ledger.Record(Events.SchedulerStartedByWolverine, "IHostedService ordered after UseWolverine");
    }

    // Nothing to do: the Quartz hosted service shuts the scheduler down whether or not it started it.
    public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

With persistence, the agent machinery is running and SingularAgent is the supported way to say "one node in the cluster does this":

protected override async Task startAsync(CancellationToken cancellationToken)
{
    started = await schedulerFactory.GetScheduler(cancellationToken);
    await started.Start(cancellationToken);

    logger.LogInformation("Scheduler '{Name}' started on this node by Wolverine", started.SchedulerName);
    Ledger.Record(Events.SchedulerStartedByWolverine, "Wolverine SingularAgent");
}

protected override async Task stopAsync(CancellationToken cancellationToken)
{
    // The scheduler the agent started, not a fresh ISchedulerFactory.GetScheduler(): on host
    // shutdown Wolverine stops its agents after the Quartz hosted service has already shut the
    // scheduler down, and asking the factory for it again throws rather than handing back the
    // shut-down instance. Holding the reference and checking Status keeps the stop quiet.
    if (started is null || started.Status is SchedulerStatus.ShuttingDown or SchedulerStatus.Shutdown)
    {
        return;
    }

    // Standby rather than Shutdown: the agent may be re-assigned to this node later, and a
    // shut-down scheduler cannot be started again in the same container.
    await started.Standby(cancellationToken);
}

Be precise about what that buys. SingularAgent is once-per-cluster but it is not leader-pinned: its EvaluateAssignmentsAsync picks assignments.Nodes.FirstOrDefault(x => !x.IsLeader) ?? assignments.Nodes.FirstOrDefault(), so it prefers a non-leader and falls back to the leader only when there is one node. Wolverine's own leader-pinned family is for transport listeners and is registered internally; a user cannot add to it. Strictly-leader-only means writing an IAgentFamily of your own and calling AssignmentGrid.RunOnLeader.

Note also what is not being asked of Wolverine here: not "which node may fire this trigger". A clustered persistent Quartz store already answers that with its own lock, so a scheduler running on every node still fires each trigger once. What this arrangement buys is that the scheduler's lifecycle is subordinate to the messaging runtime's — the bus is up before the first job can publish into it.

Sharing the outbox's transaction

A handler that writes a row, sends a message and schedules a follow-up has three writes that can disagree. Wolverine's outbox already ties the first two together. IScheduler.EnlistTransaction is how the third joins them: for the duration of the returned scope, on the current asynchronous flow, the persistent job store uses the given transaction and its connection instead of opening its own, so the INSERT into QRTZ_TRIGGERS is a statement in the caller's transaction and a rollback takes the trigger with it.

public static async Task Handle(
    ApproveRefund message,
    MessageContext context,
    IWolverineRuntime runtime,
    IScheduler scheduler,
    CancellationToken cancellationToken)
{
    IMessageDatabase database = (IMessageDatabase) runtime.Storage;

    await using NpgsqlConnection connection = new(ExampleOptions.Current.PostgresConnectionString!);
    await connection.OpenAsync(cancellationToken);
    await using DbTransaction transaction = await connection.BeginTransactionAsync(cancellationToken);

    // Wolverine's outgoing envelopes are now written into this transaction rather than sent
    // immediately. This is the manual form of what [Transactional] does for a Marten or EF Core
    // application.
    await context.EnlistInOutboxAsync(new DatabaseEnvelopeTransaction(database, transaction));

    // 1. the application's own state
    await using (NpgsqlCommand command = new(
        "insert into refunds (order_id, amount) values (@order_id, @amount)",
        connection,
        (NpgsqlTransaction) transaction))
    {
        command.Parameters.AddWithValue("order_id", message.OrderId);
        command.Parameters.AddWithValue("amount", message.Amount);
        await command.ExecuteNonQueryAsync(cancellationToken);
    }

    // 2. a message that must not be sent unless the row above survives
    await context.PublishAsync(new SendPaymentReminder(message.OrderId, message.Amount));

    // 3. the trigger, in the same transaction as both, with the commit inside the scope so the
    // scheduler is signalled once the trigger is visible to it
    using (scheduler.EnlistTransaction(transaction))
    {
        await scheduler.ScheduleJob<PaymentReminderJob, PaymentReminder>(
            new PaymentReminder(message.OrderId, message.Amount),
            TimeSpan.FromDays(7),
            new OneOffJobOptions { Group = OrderGroup.For(message.OrderId) },
            cancellationToken);

        await transaction.CommitAsync(cancellationToken);
    }

    // Releases the envelopes the outbox held back. Nothing left the process before the commit.
    await context.FlushOutgoingMessagesAsync();

    Ledger.Record(Events.RefundApprovedInTransaction, message.OrderId);

The caveats are all in SchedulerEnlistmentExtensions' own documentation, and every one of them bites here:

  • It must be turned on. ConfigureStore(o => o.AcceptEnlistedTransactions = true), or quartz.jobStore.acceptEnlistedTransactions. Without it "the job store keeps opening its own connection and managing its own transaction, and enlisting throws rather than being ignored".
  • An ambient TransactionScope on its own is not enough, "because a connection the job store opens for itself is deliberately kept out of it". Sharing the one connection is also what keeps the transaction from being promoted to a distributed one, which Npgsql does not support at all.
  • The enlistment "flows with the current asynchronous context, so it must be established in the same scope as the scheduler calls it should cover". Establishing it inside an async helper does not carry it back out to the caller.
  • The commit belongs inside the using block. Disposing the scope is what signals the scheduling loop that a trigger appeared, so disposing before the commit wakes it to look for a row it cannot yet see — and the trigger then waits for the next acquisition sweep, which is one of the few places IdleWaitTime really does bound latency.
  • "While the enlistment is in effect the job store holds its locks in the caller's transaction, so they are only released once that transaction completes. Keep enlisted transactions short: a long running one blocks trigger acquisition, the misfire handler and cluster check-in." A message handler fits that; a batch job that enlists and then works for a minute does not.
  • Both stores must be in one database. Different schemas are fine; one DbTransaction cannot span two servers.

The transaction is opened by hand rather than by Wolverine's [Transactional] attribute, and that is not a stylistic choice. Wolverine's transactional middleware supplies whatever its persistence provider supplies, and as of 6.35.0 the raw-ADO.NET Postgres package supplies nothing: neither Wolverine.Postgresql nor Wolverine.RDBMS defines an IPersistenceFrameProvider, because the ones that exist arrive with a document store or an ORM and raw ADO.NET is neither. A handler declaring [Transactional] Handle(T msg, NpgsqlTransaction tx) against plain PersistMessagesWithPostgresql compiles and then fails at runtime with "JasperFx was unable to resolve a variable of type Npgsql.NpgsqlTransaction". An application that already has Marten or EF Core can use [Transactional] and take the provider's own session or DbContext — but then the commit happens in generated code after the handler returns, so the enlistment scope necessarily disposes first, and the signal is spent on a trigger the loop cannot yet see. Nothing is lost; the trigger simply waits for the next sweep.

What this recipe does not do

  • It is not a package. There is nothing to install beyond Quartz and WolverineFx, and nothing here is covered by Quartz.NET's API compatibility promises. If JasperFx ships a first-party integration, prefer it.
  • It does not put Quartz's schedule under Wolverine's leader election. Trigger ownership is the Quartz cluster's business, and a persistent store with UseClustering() already handles it. The agent in "Letting Wolverine start the scheduler" decides which node runs a scheduler, not which node fires a trigger.
  • It does not make in-memory scheduling durable. With the default in-memory store a restart loses every pending trigger, exactly as it loses Wolverine's in-memory scheduled envelopes. Use a persistent store for anything that must survive.
  • It does not replace Wolverine's own scheduling. ScheduleAsync and TimeoutMessage remain the right answer for a delayed message or a saga timeout, and since 6.34 opts.Schedules is the right answer for a recurring publish whose only failure mode is "skip what was missed". Reach for Quartz when the schedule needs a misfire policy, a calendar, a change while the host is up, or an operator looking at it.

See also

  • One-Off Job — the ScheduleJob<TJob, TInput> one-liner in isolation
  • Rescheduling Jobs — changing a live schedule, and recovering a failed trigger
  • Job Template — the recommended skeleton for a job class
  • Running Quartz under Aspire — telemetry, health and the database, wired to an AppHost
  • Cron Expression Reference — the cron field and special-character syntax
  • Configuration Reference — every option, typed and legacy
Help us by improving this page!
Last Updated: 9/11/26, 7:37 PM
Contributors: Marko Lahma, Claude Fable 5.1
Prev
Running Quartz under Aspire
Next
Embedding Quartz in a Library