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
      • Compile-Time Checks
      • Declaring Jobs with Attributes
      • Delegate Jobs
    • Configuration Reference
    • JSON Configuration
    • Cron Expression Reference
    • Multi-Tenancy
    • Comparison
    • 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
    • Backfill
    • Retrying Failed Jobs
    • Pausing with a Reason
    • Job Continuations
    • Overlap Policy
    • Progress and Execution Logs
    • Job Outcomes
    • Multiple Triggers
    • Job Template
    • Running Quartz under Aspire
    • Quartz.NET with Wolverine
    • Coming from Hangfire
    • Coming from TickerQ
    • 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
      • Dashboard Agent
      • Hosted Services Integration
      • Microsoft DI Integration
      • Multiple Schedulers with Microsoft DI
      • Observability
      • Redis Lock Handler
      • TimeZoneConverter Integration
      • Weasel Schema Management
    • 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

Since 6.34.0 (JasperFx/wolverine#4307), Wolverine can publish a message on a cron expression with opts.Schedules.ScheduleRecurring, parsed by Cronos. It is a cron on top of Wolverine's scheduled messages, deliberately not a scheduler. This page covers when to use it, when to use Quartz, and how to run both.

There is no integration package (no WolverineFx.Quartz, no Quartz.Wolverine); one was planned for Wolverine 6 but has not shipped. This is a recipe, written against Wolverine 6.35.0. It uses Wolverine's hook for scheduling libraries, "Sending Raw Message Data", in Deferring a serialized envelope.

A working copy of all of this

src/Quartz.Examples.Wolverine in the Quartz.NET repository is this page as one console application, built with the solution; every C# block below is checked against it line for line. dotnet run --project src/Quartz.Examples.Wolverine -- --smoke runs all seven parts on the in-memory store and exits non-zero on failure. The WolverineSmoke build target runs it on every pull request, on all three operating systems, with no database.

Which library should own the schedule

UseWhen
Wolverine's opts.Schedulespublish a message on a cron, skipping whatever the process was down for
a Quartz trigger that publishesa missed firing needs a decided outcome; the schedule changes while the host runs; some dates are excluded; or runs must not overlap

For the first case Wolverine's schedule is three lines in UseWolverine, rides the existing outbox, and needs no second runtime.

A transport delay applies to one message; a recurrence is a rule that outlives its messages. Other stacks:

StackRecurring schedules
Azure Service Busnot supported; scheduling is per message
Amazon SQSdelay capped at 15 minutes; EventBridge Scheduler beyond that
RabbitMQ delayed-message-exchange pluginseconds to a day or two, one unreplicated table; unmaintained since Mnesia was removed in RabbitMQ 4.3.0
NServiceBusscheduler deprecated in favour of sagas and schedulers such as Hangfire, Quartz and FluentScheduler
Rebusnone
Brighter"at this time" and "after this delay"; cron left to the backend, Quartz being one
MassTransita cron parser in its Job Service since 2024; still ships MassTransit.Quartz, which needs Quartz.NET 3.x
Wolverineopts.Schedules since 6.34: a cron deciding when an occurrence is published

Comparison has the full side-by-side, including Hangfire, TickerQ and Coravel.

Setting the two up

Both are hosted services in one host. Register Wolverine first, so it is running before anything publishes:

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!);
    }
});

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

Register Quartz as usual; the persistent branch is for the last two sections:

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

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

            // Weasel creates and migrates Quartz's tables with Wolverine's: at startup, and from
            // db-apply and resources setup. Quartz.Weasel.PostgreSQL; ProvisionSchema() without it.
            store.UseWeaselForPostgres();

            // 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.
});

UseWeaselForPostgres() hands Quartz's tables to Weasel, the tool Wolverine migrates its own tables with, so db-apply, db-assert and resources setup cover both. It needs the Quartz.Weasel.PostgreSQL package. Without it, use ProvisionSchema() and the scripts under database/migrations/.

Wolverine's own schedules

Register it inside UseWolverine; the Quartz scheduler never sees it.

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));
}
  • One SingularAgent per cluster keeps the next occurrence of each schedule queued as an ordinary scheduled message, so delivery, durability and replay are Wolverine's.
  • Each occurrence has a deterministic deduplication id, {name}:{occurrenceUtc:O}, so a re-publish after agent failover collapses at consumption.
  • With a relational message store, wolverine_recurring_messages records which schedule owns which pending envelope; the agent checks it is still in the inbox, and a successor adopts it instead of publishing again.

Documented limits:

  • Nothing faster than every five seconds, the default DurabilitySettings.ScheduledJobPollingTime; a faster cron is refused at registration. So the smoke run uses */5 * * * * * here, while part 1's Quartz trigger fires every two seconds under --smoke.
  • Without a message store (a startup warning), occurrences in a restart window are lost and there is no store-backed deduplication. DurabilityMode.Serverless and DurabilityMode.MediatorOnly run no agents, so a host in either mode with a schedule refuses to start.
  • Missed occurrences are skipped, never back-filled, including during a PauseAsync / ResumeAsync window. This is the main difference from a Quartz trigger.

When the schedule still belongs in Quartz

For the cases in the table above, use a Quartz job that publishes. Take IMessageBus in the constructor; Quartz resolves the job in a fresh scope per firing, so a scoped IMessageBus is right.

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> receives the payload as a parameter, not 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, not the clock: a late firing after a misfire still reports its scheduled time, which is the window the run covers.

Scheduling one firing from a handler

A handler can take IScheduler and schedule a firing in one call. OneOffJobOptions.Group sets the trigger's group, the correlation axis for one order, saga or tenant:

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}");
    }
}

Group names the trigger, not the job: one durable job detail per job type is stored under the QRTZ_SCHEDULED group, and each call adds a trigger.

Cancelling by correlation

Withdrawing everything for one order is one store operation, with the matcher evaluated in the store:

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}");
    }
}

Azure Service Bus (CancelScheduledMessageAsync with a sequence number), MassTransit (CancelScheduledMessage with a TokenId) and Hangfire (BackgroundJob.Delete with a job id) cancel one schedule per call, by a handle you keep. NServiceBus saga timeouts cannot be cancelled; the saga ignores them on arrival. Quartz's group matchers (GetTriggerKeys, UnscheduleJobs, PauseTriggerGroups, DeleteJobs) make "everything this tenant owns" a query.

Deferring a serialized envelope

Wolverine's SendRawMessageAsync takes a byte[]. Produce it with WolverineOptions.DefaultSerializer.WriteMessage(message) and store it as a typed job input:

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 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;
    });
}
  • Envelope.MessageType comes from the stored name, because the job does not know the type statically. typeof(T).ToMessageTypeName() honours a [MessageIdentity] alias; a raw FullName would not.
  • Set Envelope.Destination by hand. In Wolverine 6.35.0, SendRawMessageAsync sets Sender but not Destination, and Executor.ExecuteAsync logs through envelope.Destination!. Without that line the message is still delivered, but a NullReferenceException from Executor.ExecuteAsync is logged.

Use this when the payload must be fixed at the moment of the decision: stored bytes do not change with later application state or message-contract changes.

What the latency settings actually do

Quartz's 30-second IdleWaitTime beside Wolverine's 5-second ScheduledJobPollingTime does not make Quartz six times slower to deliver a due message:

  • QuartzSchedulerThread acquires triggers due within the next IdleWaitTime and waits for the exact fire time.
  • Every in-process ScheduleJob, AddTrigger, RescheduleJob or DeleteJob wakes the loop, unless the trigger is due after the loop's next look, which finds it anyway. A trigger scheduled from a handler through this process's IScheduler never waits for a sweep.
  • IdleWaitTime bounds only the pickup of work scheduled elsewhere (another node, a recovered trigger) and the look-ahead of one acquisition. It matters in the last section.

The three settings worth changing in front of a 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 automatic: the pool on a persistent store, clustered or not, 1 in memory.
    // Must not exceed ThreadPoolOptions.MaxConcurrency, which defaults to 10.
    options.MaxBatchSize = 10;

    // Default TimeSpan.Zero, which batches only triggers already due. This also batches
    // triggers due up to half a second after the first, and fires them that much early.
    options.BatchTriggerAcquisitionFireAheadTimeWindow = TimeSpan.FromMilliseconds(500);
});

With the default window of TimeSpan.Zero a batch takes the triggers already due, or due at the same instant as the first. Set the window to how early a firing may run. MaxBatchSize must not exceed the thread pool's MaxConcurrency; IdleWaitTime has a floor of one second.

Letting Wolverine start the scheduler

With AutoStart = false the scheduler is built, initialized and bound, and waits in SchedulerStatus.Created until something calls Start. Shutdown is unaffected: the hosted service stops every scheduler it created.

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

Without persistence, Wolverine runs no agents: WolverineRuntime.startAgentsAsync begins with if (Storage is NullMessageStore) { ...; return; }, so IAgentFamily registrations are never read and AddSingularAgent<T>() silently never starts. Register an IHostedService after UseWolverine (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, use a SingularAgent, which runs on one node in the cluster:

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);
}
  • SingularAgent is not leader-pinned: EvaluateAssignmentsAsync picks assignments.Nodes.FirstOrDefault(x => !x.IsLeader) ?? assignments.Nodes.FirstOrDefault(), preferring a non-leader. Wolverine's leader-pinned family is internal, for transport listeners. For leader-only, write an IAgentFamily that calls AssignmentGrid.RunOnLeader.
  • This does not decide which node fires a trigger; a clustered persistent Quartz store does that with its own lock. It only makes the bus start before the first job can publish.

Sharing the outbox's transaction

To make a row, a message and a scheduled follow-up commit together, add IScheduler.EnlistTransaction to Wolverine's outbox. While the returned scope is open, on the current asynchronous flow, the persistent job store uses the given transaction and connection, so a rollback removes the QRTZ_TRIGGERS row too.

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);

From SchedulerEnlistmentExtensions:

  • Turn it on with ConfigureStore(o => o.AcceptEnlistedTransactions = true) or quartz.jobStore.acceptEnlistedTransactions; otherwise enlisting throws.
  • An ambient TransactionScope alone is not enough: the store keeps its own connections out of it. Sharing one connection also avoids promotion to a distributed transaction, which Npgsql does not support.
  • Enlist in the same scope as the scheduler calls; set inside an async helper, it does not flow back.
  • Commit inside the using block. Disposing signals the loop; disposed before the commit, the loop finds nothing, and the trigger waits for the next sweep (bounded by IdleWaitTime).
  • Keep the transaction short. The store's locks are held until it completes, blocking acquisition, the misfire handler and cluster check-in.
  • One database. Different schemas are fine; one DbTransaction cannot span two servers.

The transaction is opened by hand because, as of 6.35.0, Wolverine's [Transactional] gets nothing from the raw-ADO.NET Postgres package: neither Wolverine.Postgresql nor Wolverine.RDBMS defines an IPersistenceFrameProvider. [Transactional] Handle(T msg, NpgsqlTransaction tx) with plain PersistMessagesWithPostgresql compiles, then fails with "JasperFx was unable to resolve a variable of type Npgsql.NpgsqlTransaction". With Marten or EF Core, [Transactional] can supply the provider's session or DbContext, but its generated commit runs after the handler returns, after the enlistment scope has disposed; the trigger then waits for the next sweep.

What this recipe does not do

  • It is not a package. Install Quartz and WolverineFx, and Quartz.Weasel.PostgreSQL for the schema. The wiring here is not covered by Quartz.NET's API compatibility promises; prefer a first-party JasperFx integration if one ships.
  • It does not put Quartz under Wolverine's leader election. A persistent store with UseClustering() owns triggers; the agent in Letting Wolverine start the scheduler only decides which node runs a scheduler.
  • It does not make in-memory scheduling durable. A restart loses every pending trigger, as it loses Wolverine's in-memory scheduled envelopes.
  • It does not replace Wolverine's scheduling. Use ScheduleAsync and TimeoutMessage for delayed messages and saga timeouts, and opts.Schedules (6.34+) for recurring publishes that may skip missed runs. Use Quartz for a misfire policy, a calendar, changes while running, or operator visibility.

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: 10/16/26, 2:35 AM
Contributors: Marko Lahma, Claude Opus 5.5
Prev
Running Quartz under Aspire
Next
Coming from Hangfire