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

In 4.x every part of a scheduler reads the current time from one TimeProvider, injected like any other service: the scheduling loop, every trigger's fire-time computation, the misfire handler and the cluster check-in.

SystemTime is gone

3.x had SystemTime.UtcNow, a mutable static Func<DateTimeOffset> you assigned to. It was global, so two tests that each wanted a fake clock could not run at the same time.

4.x uses the .NET TimeProvider as a per-scheduler service. The clock is injected, not assigned, and one scheduler's fake clock does not affect another.

Tips

DateTime.Now, DateTime.Today, DateTimeOffset.Now, DateTimeOffset.Today and the implicit DateTime → DateTimeOffset conversion are banned in the Quartz codebase by an analyzer. UtcNow is allowed where there is no scheduler to ask. Avoid them in your own jobs too: a job that reads DateTime.Now cannot be tested on a fake clock.

Setting the clock

One call, on either builder:

builder.Services.AddQuartz(q =>
{
    q.UseTimeProvider(myTimeProvider);
});
IScheduler scheduler = await QuartzSchedulerBuilder
    .Create(q => q.UseTimeProvider(myTimeProvider))
    .BuildScheduler();

Precedence

Most specific first:

  1. UseTimeProvider(...) on this scheduler. Wins over everything.
  2. A TimeProvider registered in the container. A named scheduler with no clock of its own inherits the application's.
  3. The legacy quartz.timeProvider.type property key.
  4. TimeProvider.System.
  • The container-wide default is registered with TryAddSingleton. An application that already registers a TimeProvider (services.AddSingleton(TimeProvider.System), or a test clock) keeps it, and every scheduler uses it.
  • UseTimeProvider on a named scheduler registers the clock keyed by that scheduler's name. On the default scheduler it replaces the container's unkeyed TimeProvider. So a fake clock given to one named scheduler does not change the others.

How far the clock reaches

Everything the container builds for a scheduler gets that scheduler's clock:

ComponentWhat it uses the clock for
QuartzScheduler / the scheduling loopdeciding whether a trigger is due, waiting for it, StartDelayed
RAMJobStorefire times, misfire detection
The ADO.NET storethe same, plus retry backoff
IDriverDelegate (via DriverDelegateContext.TimeProvider)timestamps written to the database
ILockHandler (via LockHandlerContext.TimeProvider)lock-acquisition backoff
MisfireHandlerits scan interval
ClusterManagercheck-in interval and failed-node detection

A custom job store, driver delegate or lock handler gets the same clock by taking a TimeProvider constructor parameter or reading the one its context carries. It is resolved for the scheduler the component belongs to.

Builders and the clock

Only the trigger builder takes a clock:

TriggerBuilder.Create(TimeProvider? timeProvider = null);
TriggerBuilder.Create<TJob>(TimeProvider? timeProvider = null);

The builder uses the clock:

  • as the default StartTimeUtc when you do not call StartAt;
  • in StartNow();
  • passed to the schedule builder at Build(), so a schedule computed there (DailyTimeIntervalScheduleBuilder.EndingDailyAfterCount(n)) uses the same clock;
  • passed to the trigger, which keeps it. See Which clock a trigger holds.

The five schedule builders take no clock. They describe a shape (every day at 09:00, every 15 minutes, the third Tuesday); when one needs "now", it gets it from the trigger builder.

CronScheduleBuilder.Create(cronExpression);
SimpleScheduleBuilder.Create();
CalendarIntervalScheduleBuilder.Create();
DailyTimeIntervalScheduleBuilder.Create();
RecurrenceScheduleBuilder.Create(recurrenceRule);

DateBuilder has two statics, both taking an optional clock:

DateTimeOffset when = DateBuilder.Create(timeProvider).InYear(2027).InMonthOnDay(3, 15).AtHourOfDay(9).Build();
DateTimeOffset local = DateBuilder.CreateInTimeZone(tz, timeProvider).AtHourMinuteAndSecond(9, 30, 0).Build();

Changed in 4.x

DateBuilder.NewDate / NewDateInTimeZone are now Create / CreateInTimeZone, and CronScheduleBuilder.CronSchedule(...) is CronScheduleBuilder.Create(...): the whole family uses a Create factory. DailyTimeIntervalScheduleBuilder.Create() also lost its TimeProvider parameter; it takes the trigger builder's clock, so EndingDailyAfterCount respects a fake clock.

The trap: triggers built outside the container

AddTrigger<TJob> and ScheduleJob<T> in DI configuration create their builder as TriggerBuilder.Create<TJob>(serviceProvider.GetService<TimeProvider>()), so a trigger configured there starts on the scheduler's clock:

builder.Services.AddQuartz(q =>
{
    q.UseTimeProvider(fakeClock);

    // this trigger's implicit start time is the fake clock's now
    q.AddTrigger<ReportJob>(t => t
        .WithSimpleSchedule(s => s.WithInterval(TimeSpan.FromHours(1)).RepeatForever()));
});

A trigger you build yourself does not:

// StartTimeUtc is the WALL CLOCK, whatever the scheduler's TimeProvider says
ITrigger trigger = TriggerBuilder.Create()
    .WithIdentity("hourly")
    .WithSimpleSchedule(s => s.WithInterval(TimeSpan.FromHours(1)).RepeatForever())
    .Build();

This is the most common surprise in a fake-clock test: the scheduler is on 2024-01-01, but the trigger starts at the real current time. Pass the clock:

ITrigger trigger = TriggerBuilder.Create(fakeClock)
    .WithIdentity("hourly")
    .StartAt(fakeClock.GetUtcNow())
    .WithSimpleSchedule(s => s.WithInterval(TimeSpan.FromHours(1)).RepeatForever())
    .Build();

In tests, set StartAt explicitly; then the builder's clock does not matter.

Which clock a trigger holds

A trigger is given a clock when it is created and keeps it. Everything it later reads as "now" (the past-due clamp in ComputeFirstFireTimeUtc, and all of UpdateAfterMisfire) comes from that clock.

A trigger produced byholds
TriggerBuilder.Create(clock)clock
TriggerBuilder.Create()TimeProvider.System
AddTrigger<TJob> / ScheduleJob<T> in DI configurationthe scheduler's TimeProvider
trigger.GetTriggerBuilder().Build()whatever trigger held
new CronTriggerImpl(clock) and its siblingsclock, or TimeProvider.System when omitted
a job store reading it backthe clock that store's scheduler runs on

For the last row:

  • RAMJobStore returns the object it was given, so a stored trigger keeps the clock that built it.
  • The ADO.NET store builds a new trigger on every read and gives it the store's clock. That includes a trigger deserialized from BLOB_TRIGGERS, because the clock field is not serialized.

The store detects a misfire by its own clock, and the trigger computes the recovery by its own clock. They must be the same clock; otherwise a scheduler on a FakeTimeProvider recovers a 2024 misfire onto today.

Warning

The clock is not part of a trigger's public surface: there is no ITrigger.TimeProvider to read or assign. It is set when the trigger is constructed, or by the store that materialized it, and nothing else changes it.

Time zones are a separate axis

TimeProvider says what instant it is. TimeZoneInfo says what that instant looks like where the schedule lives. They are independent: a fake clock does not fake a time zone.

TriggerBuilder.Create()
    .WithCronSchedule("0 0 9 * * ?", x => x.InTimeZone(TimeZones.FindById("Europe/Helsinki")))
    .Build();
TimeZones memberDoes
FindById(string id)looks up a zone; use it instead of TimeZoneInfo.FindSystemTimeZoneById
GetUtcOffset(DateTime, TimeZoneInfo)returns the offset; an ambiguous (repeated) local time resolves to the daylight instance, the first of the two
AddResolver(Func<string, TimeZoneInfo?>)registers a fallback lookup; returns an IDisposable that removes it
  • FindById tries, in order: the platform, a built-in alias table (UTC, CET, US/Eastern and friends), IANA-to-Windows conversion, then registered resolvers. The platform goes first because converting first would rewrite US/Eastern to Eastern Standard Time, and a job store would write the rewritten id back into TIME_ZONE_ID.
  • Resolvers are consulted most-recently-added first, and are process-wide, because FindById is called where no scheduler is in scope (parsing a cron expression, deserializing a trigger from a blob). Quartz.Plugins.TimeZoneConverter installs one and disposes it at scheduler shutdown.

Changed in 4.x

TimeZoneUtil is now TimeZones, and CustomResolver is AddResolver, which returns a registration you dispose rather than a property you assign.

Daylight saving behaviour differs by trigger family:

  • CronTriggers: a cron time that does not exist on a spring-forward day, and one that happens twice on a fall-back day
  • More About Triggers: calendar-interval triggers, PreserveHourOfDayAcrossDaylightSavings and SkipDayIfHourDoesNotExist
  • Testing: how to check your schedule, in microseconds and with no scheduler

Testing with a fake clock

Microsoft.Extensions.TimeProvider.Testing provides FakeTimeProvider:

FakeTimeProvider clock = new(new DateTimeOffset(2026, 3, 1, 0, 0, 0, TimeSpan.Zero));

builder.Services.AddQuartz(q => q.UseTimeProvider(clock));

Advancing the fake clock moves the scheduler's waits as well as its computations. clock.Advance(TimeSpan.FromHours(1)) fires a trigger that came due in that hour.

  • The scheduling loop's waits, idle, before a firing and in standby, are timers on the TimeProvider.
  • The misfire handler's scan and the cluster check-in sleep on it too.
  • So do fire-time computation, misfire detection, StartDelayed, and the retry and backoff delays in the ADO store.
  • A shutdown does not wait for the clock: it cancels every wait, and gives up on a stuck store call or a still-running job in real time.

Testing shows a test that advances the clock, and covers the four levels of Quartz test, starting with computing fire times with no scheduler. IdleWaitTime, misfire thresholds and the other timings are in the Configuration Reference.

Legacy: the property key

quartz.timeProvider.type still works. It names a type with a parameterless constructor:

quartz.timeProvider.type = MyApp.TestClock, MyApp
  • It is registered with TryAdd semantics. The configuration callback runs first, so UseTimeProvider wins.
  • It does replace Quartz's own TimeProvider.System fallback, so the key is never read and then ignored.
Help us by improving this page!
Last Updated: 10/6/26, 2:13 PM
Contributors: Marko Lahma, Claude Opus 5.5
Prev
RecurrenceTrigger
Next
Trigger and Job Listeners