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
      • 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

Running Quartz under Aspire

Aspire (".NET Aspire" before Aspire 13) orchestrates a local application. Its AppHost declares databases and projects and wires them together; ServiceDefaults, a project every service references, turns on OpenTelemetry, health checks, service discovery and HTTP resilience. Quartz.Aspire connects a scheduler to both in one call:

builder.AddQuartzPersistentStore("quartz");
builder.AddQuartz();
builder.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);

Documented against Aspire 13.5. Settings, the provider table and the connection ladder are on the Aspire Integration reference page. There is no hosting integration (no Aspire.Hosting.Quartz package, no AddQuartz AppHost resource): Quartz.Aspire only needs a named connection string, from anywhere.

What the package subscribes for you

ServiceDefaults' OpenTelemetry pipeline subscribes to a fixed list (runtime, ASP.NET Core and HttpClient metrics and traces, and the application's own ActivitySource), which does not include Quartz. AddQuartzPersistentStore:

  • calls AddOpenTelemetry() and adds Quartz's activity source and meter (both "Quartz", constants in Quartz.Diagnostics);
  • registers the scheduler's health check on the IHealthChecksBuilder that holds ServiceDefaults' self check;
  • works before or after AddServiceDefaults(), because OpenTelemetry keeps one TracerProvider and one MeterProvider per container and a second AddOpenTelemetry() composes with the first.

With DisableTracing and DisableMetrics set, or without Quartz.Aspire, subscribe by hand:

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing => tracing.AddSource(QuartzInstrumentation.ActivitySourceName))
    .WithMetrics(metrics => metrics.AddMeter(QuartzInstrumentation.MeterName));

Do not add an exporter here

ServiceDefaults calls UseOtlpExporter() whenever OTEL_EXPORTER_OTLP_ENDPOINT is set, and the AppHost sets it. Calling it twice, or combining it with a signal-specific AddOtlpExporter(), throws NotSupportedException. For an application without ServiceDefaults, see Observability.

The AppHost

dotnet new install Aspire.ProjectTemplates
dotnet new aspire-apphost -n AppHost
dotnet sln add AppHost/AppHost.csproj

aspire-service-defaults creates the ServiceDefaults project. You need a container runtime (Docker Desktop, Podman), but not the aspire CLI or a workload: Aspire.AppHost.Sdk brings the dashboard and the Developer Control Plane, so dotnet run on the AppHost is enough. See getting started.

A Postgres server, a database, and the worker:

var builder = DistributedApplication.CreateBuilder(args);

var postgres = builder.AddPostgres("postgres")
    .WithDataVolume()
    .WithLifetime(ContainerLifetime.Persistent);

var quartzDb = postgres.AddDatabase("quartz");

builder.AddProject<Projects.Orders_Worker>("orders")
    .WithReference(quartzDb)
    .WaitFor(quartzDb);

builder.Build().Run();
  • AddDatabase("quartz") names the resource and, with no second argument, the database.
  • WithReference passes the connection string as ConnectionStrings__quartz, read as ConnectionStrings:quartz: the AppHost name is the name the worker asks for.
  • WaitFor holds the worker until Postgres is healthy.
  • WithDataVolume() and WithLifetime(ContainerLifetime.Persistent) keep the database across restarts; without them the job store starts empty every run.

The worker

var builder = Host.CreateApplicationBuilder(args);

builder.AddServiceDefaults();
builder.AddNpgsqlDataSource("quartz");

builder.AddQuartzPersistentStore("quartz");
builder.AddQuartz();
builder.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);
  • AddServiceDefaults is your own code, generated by the template; it is generic over IHostApplicationBuilder, so workers can use it.
  • AddNpgsqlDataSource comes from Aspire.Npgsql, which brings the Npgsql driver. It registers the unkeyed DbDataSource that AddQuartzPersistentStore looks for.
  • Register the data source first. AddQuartzPersistentStore sees only services registered before it. The order of the three Quartz calls does not matter.
dotnet add package Aspire.Npgsql

A working copy of all of this

src/Quartz.Examples.Aspire.AppHost and src/Quartz.Examples.Aspire.Worker in the Quartz.NET repository are this page as two projects. They are in the solution, so a call on this page that stops compiling fails the build.

Without an AppHost

Quartz.Aspire does not need one. It references no Aspire.* package, and AddQuartzPersistentStore(name) reads ConnectionStrings:<name> from any IConfiguration source, such as appsettings.json:

{
  "ConnectionStrings": {
    "quartz": "Host=localhost;Database=quartz;Username=postgres;Password=postgres"
  }
}
var builder = Host.CreateApplicationBuilder(args);

builder.AddQuartzPersistentStore("quartz");
builder.AddQuartz();
builder.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);

You still get provider inference, Aspire:Quartz:* binding, per-scheduler sections, the health check, telemetry and schema provisioning under Development. Reference the driver yourself (dotnet add package Npgsql). An AppHost adds the container, connection string, startup order and dashboard; the rest of this page assumes one.

What the call chose, and how to choose it yourself

builder.AddQuartz(q =>
{
    q.ConfigureScheduler(options =>
    {
        options.InstanceName = "orders";
        options.GenerateInstanceId = true;
    });

    q.UsePersistentStore(store =>
    {
        store.UsePostgres(db => db.UseRegisteredDataSource = true);
        store.UseClustering();
    });
});

builder.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);
  • UseRegisteredDataSource resolves the container's one unkeyed DbDataSource. It is a DataSourceOptions setting, like ConnectionString and ConnectionStringName, and wins over both.
  • UsePostgres is still needed: it selects PostgreSQLDelegate and the Npgsql driver description (SQL and parameter shape); the data source supplies connections.
  • Commands come from the data source's connections, so its type mappers, logging and multiplexing apply to Quartz's statements. Prefer an existing NpgsqlDataSource to a connection string.
  • GenerateInstanceId and UseClustering are for replicas; see Clustering. settings.Clustered = true sets both. Nodes that all keep the default NON_CLUSTERED id are not a cluster.

More than one database

AddKeyedNpgsqlDataSource("quartz") registers the DbDataSource under a service key, and AddQuartzPersistentStore("quartz") looks for a keyed data source under "quartz" before an unkeyed one. By hand, set DataSourceServiceKey, which implies UseRegisteredDataSource:

builder.AddQuartz(q => q.UsePersistentStore(store =>
    store.UsePostgres(db => db.DataSourceServiceKey = "quartz")));

One scheduler has one store, so two databases usually means two schedulers. Name each with QuartzAspireSettings.SchedulerName; see More than one scheduler.

SQL Server takes the connection-string path

Microsoft.Data.SqlClient has no DbDataSource, and Aspire's SQL Server integration registers a scoped SqlConnection, so UseRegisteredDataSource would fail at first use. Read the connection string by name (only WithReference is needed, no client integration):

builder.AddQuartz(q => q.UsePersistentStore(store =>
    store.UseSqlServer(db => db.ConnectionStringName = "quartz")));

AddQuartzPersistentStore does this automatically for SQL Server and skips the data-source probe. The same shape works for Postgres when there is no data source to share.

Getting the tables there

Quartz never migrates its schema. Production runs the scripts in database/tables; see Database Schema. For development, store.ProvisionSchema() (4.0+) creates what is missing at start. AddQuartzPersistentStore chooses from builder.Environment:

EnvironmentSchemaProvisioning
DevelopmentCreateIfMissing
any otherValidate (the default); the scheduler's account usually cannot, and should not, run DDL

See Creating the schema. To choose explicitly, set QuartzAspireSettings.SchemaProvisioning (the same three-valued SchemaProvisioning):

builder.AddQuartzPersistentStore(
    "quartz",
    settings => settings.SchemaProvisioning = SchemaProvisioning.CreateIfMissing);

A value set on the store itself, through ConfigureStore or Quartz:JobStore:SchemaProvisioning, wins:

builder.AddQuartz(q => q.UsePersistentStore(store =>
    store.ConfigureStore(options => options.SchemaProvisioning = SchemaProvisioning.None)));

Validate cannot be set that way, because an unconfigured store already holds it; set SchemaProvisioning = SchemaProvisioning.Validate on QuartzAspireSettings. SchemaProvisioning.None skips the startup check, which the old bool could not.

Aspire's hooks do not apply a production schema. AddDatabase creates the database (on the server's ResourceReadyEvent), but:

  • WithCreationScript runs one command against the server's default database (Database=postgres), meant for CREATE DATABASE, not tables. A failure other than "database already exists" is logged, not thrown, so the AppHost stays green with no tables.
  • WithInitFiles copies files into /docker-entrypoint-initdb.d, which Postgres runs only when it first initializes its data directory, inside POSTGRES_DB, before Aspire's CREATE DATABASE. It works if POSTGRES_DB equals the AddDatabase name, but with WithDataVolume() the schema stays at whatever the first run created.

Use the shape Aspire teaches for EF Core migrations: a small project that applies the schema and exits, awaited with WaitForCompletion. (AddEFMigrations shells out to dotnet ef database update, so it cannot run a hand-written tables_postgres.sql.)

var migrations = builder.AddProject<Projects.Orders_Migrations>("migrations")
    .WithReference(quartzDb)
    .WaitFor(quartzDb);

builder.AddProject<Projects.Orders_Worker>("orders")
    .WithReference(quartzDb)
    .WaitForCompletion(migrations);

WaitFor alone only proves the server is up, not that QRTZ_TRIGGERS exists. Provisioning does not replace this in production: it needs DDL rights, and does not move an existing schema forward. database/migrations/ does that, and the 3.x → 4.0 upgrade is still mandatory.

Health

MapDefaultEndpoints() serves the scheduler's check from /health with no further wiring. With DisableHealthChecks, or without Quartz.Aspire, register it from the core package (no web reference needed; see Hosted Services Integration):

builder.Services.AddHealthChecks().AddQuartz();

To show it in the dashboard, point the AppHost at the endpoint; the project must serve HTTP:

builder.AddProject<Projects.Orders_Api>("api")
    .WithHttpHealthCheck("/health")
    .WithReference(quartzDb)
    .WaitFor(quartzDb);

WithHttpHealthCheck throws while the AppHost is built if the resource has no http or https endpoint (no launch profile, no WithHttpEndpoint()).

SchedulerQuartz reports/health answersDashboard shows
Running, and its store answersHealthy200Running
In standbyDegraded200Running
Running, but the store threw SchedulerExceptionUnhealthy503Running (Unhealthy)
Created but never started, shutting down, or shut downUnhealthy503Running (Unhealthy)

Standby reports degraded: healthy would hide a scheduler that never started, unhealthy would remove a node doing what it was told. But ASP.NET Core maps Degraded to 200 by default, and WithHttpHealthCheck accepts exactly one status code (200 unless you pass another), so an HTTP probe can never show Running (Degraded). To make standby visible, map Degraded to 503, which shows it as unhealthy:

app.MapHealthChecks("/health", new HealthCheckOptions
{
    ResultStatusCodes =
    {
        [HealthStatus.Healthy] = StatusCodes.Status200OK,
        [HealthStatus.Degraded] = StatusCodes.Status503ServiceUnavailable,
        [HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable,
    },
});

Or read the /health body, where the default writer puts the aggregate status. Quartz.Aspire maps nothing itself: ResultStatusCodes is the application's decision.

The check carries no tags

So it is in /health but not /alive, which keeps only checks tagged live; standby should not fail liveness. To add a tag, configure the options rather than registering the check again:

builder.Services.Configure<QuartzHealthCheckOptions>(options => options.Tags.Add("live"));

The registration reads IOptionsMonitor<QuartzHealthCheckOptions> when the health-check service is built, so any Configure call reaches it, including for AddQuartzPersistentStore's registration. For a named scheduler, configure the options under its name.

A worker project has no health endpoint at all

MapDefaultEndpoints needs a WebApplication; a Microsoft.NET.Sdk.Worker project has no HTTP server, so WithHttpHealthCheck has nothing to poll. Aspire's own worker samples skip MapDefaultEndpoints() for this reason and document no fix. Quartz's fix: switch the SDK to Microsoft.NET.Sdk.Web, use WebApplication.CreateBuilder, and map the endpoint. The application still hosts only the scheduler.

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.AddQuartzPersistentStore("quartz");
builder.AddQuartz();
builder.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);

WebApplication app = builder.Build();

app.MapHealthChecks("/health");

app.Run();

MapDefaultEndpoints() maps /health and /alive; the MapHealthChecks above is the first. Aspire gave the same answer on microsoft/aspire#4045.

Without Kestrel: register a custom IHealthCheck in the AppHost and attach it with .WithHealthCheck("quartz-ready"). It queries the database directly, and is the only route that can report a resource as Degraded.

ServiceDefaults maps both endpoints only in development

MapDefaultEndpoints maps its health endpoints only when IsDevelopment(), for security. Locally that is fine; deployed, WithHttpHealthCheck("/health") polls a 404 wherever that guard applies.

Logs

No setup is needed. Everything the container builds for a scheduler logs through its ILoggerFactory (the loop; the job store with its cluster manager, misfire handler, driver delegate and lock handler; the thread pool, job factory, type loader and instance id generator that Use*<T>() chose), and ServiceDefaults has put an OpenTelemetry provider on it.

Types no container builds read the ambient factory: a listener or trigger you constructed, CronTriggerImpl, the static helpers, and the jobs in Quartz.Jobs. Forward the host's factory to LogProvider.SetLogProvider once to include them; see the migration guide.

The loop opens one logging scope for its run with quartz.scheduler.name and quartz.scheduler.id. ServiceDefaults sets IncludeScopes = true, so they become attributes on every line, including job lines (the scope flows through the execution context captured at dispatch), and the dashboard's advanced log filter can select on them. That matters when a process runs several schedulers, because the logger category is only a type name. From 4.3, q.AddJobLogScope() adds the job, trigger and fire instance to every line a firing logs; see A log scope per firing.

What the dashboard shows

Traces.

  • One Quartz.Job.Execute span per firing (ActivityKind.Internal), and a Quartz.Job.Veto span when a trigger listener refuses.
  • View details shows quartz.job.name, quartz.job.group, quartz.trigger.name and quartz.trigger.group. quartz.scheduler.name, quartz.scheduler.id, quartz.job.type and quartz.fire.instance.id appear only when the span is sampled for full data.
  • A failed firing sets error status and tags error.type. It adds an exception event unless turned off. Every Quartz.Job.Execute span carries quartz.job.result.
  • One Quartz.JobStore.* span per store operation (Quartz.JobStore.AcquireNextTriggers, .TriggersFired, .ScheduleJob and the rest), ActivityKind.Client, from every store including in-memory.

Metrics (the Quartz meter):

  • quartz.job.execution.duration, a histogram in seconds (charted as P50, P90, P99), and quartz.job.execution.active, an up-down counter of running jobs. Both carry quartz.scheduler.name, quartz.scheduler.id and the four job and trigger identity attributes; the histogram adds error.type on failure, so the failure rate is its count with that attribute. quartz.fire.instance.id is not a metric attribute (it is unique per firing).
  • quartz.trigger.misfire, quartz.trigger.acquisition.duration, quartz.trigger.acquired, quartz.jobstore.operation.duration, and on a clustered persistent store quartz.cluster.checkin.duration and quartz.cluster.recovery.trigger. All carry quartz.scheduler.id, so you can filter to one node.

Full tables: Observability.

What Aspire does not do for you

  • Clustering. WithReplicas(2) starts two workers, not a cluster. Add UseClustering() and a distinct InstanceId per node: GenerateInstanceId = true uses the host name and a timestamp, distinct across replicas on one machine. A node finds its own check-in row and fired triggers by that id, so every replica keeping NON_CLUSTERED breaks clustering. QuartzAspireSettings.Clustered sets both.
  • Store settings: table prefix, serializer, misfire threshold, lock strategy. See Configuration Reference.
  • Startup order beyond the container. WaitFor waits for the database's health check, not the Quartz tables; wait for your migration step instead.
  • History. The Aspire dashboard keeps telemetry in memory for the session. For last week's failed job, point the OTLP export at a collector; ServiceDefaults owns the exporter, so Quartz needs no change.

Tips

Nothing here is Aspire-specific. AddServiceDefaults() is a template you own, so this works in any generic-host application that configures OpenTelemetry; Observability is the version without Aspire. AddQuartzPersistentStore works anywhere a ConnectionStrings: entry does.

See also

  • Aspire Integration — every setting, the provider-inference table, and the connection ladder in full
  • Observability — the spans, instruments and attributes
  • Operations — the health check outside Aspire, and what it does and does not assert
Help us by improving this page!
Last Updated: 10/9/26, 10:00 AM
Contributors: Marko Lahma, Claude Fable 5.1
Prev
Job Template
Next
Quartz.NET with Wolverine