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:
UseTimeProvider(...)on this scheduler. Wins over everything.- A
TimeProviderregistered in the container. A named scheduler with no clock of its own inherits the application's. - The legacy
quartz.timeProvider.typeproperty key. TimeProvider.System.
- The container-wide default is registered with
TryAddSingleton. An application that already registers aTimeProvider(services.AddSingleton(TimeProvider.System), or a test clock) keeps it, and every scheduler uses it. UseTimeProvideron a named scheduler registers the clock keyed by that scheduler's name. On the default scheduler it replaces the container's unkeyedTimeProvider. 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:
| Component | What it uses the clock for |
|---|---|
QuartzScheduler / the scheduling loop | deciding whether a trigger is due, waiting for it, StartDelayed |
RAMJobStore | fire times, misfire detection |
| The ADO.NET store | the same, plus retry backoff |
IDriverDelegate (via DriverDelegateContext.TimeProvider) | timestamps written to the database |
ILockHandler (via LockHandlerContext.TimeProvider) | lock-acquisition backoff |
MisfireHandler | its scan interval |
ClusterManager | check-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
StartTimeUtcwhen you do not callStartAt; - 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 by | holds |
|---|---|
TriggerBuilder.Create(clock) | clock |
TriggerBuilder.Create() | TimeProvider.System |
AddTrigger<TJob> / ScheduleJob<T> in DI configuration | the scheduler's TimeProvider |
trigger.GetTriggerBuilder().Build() | whatever trigger held |
new CronTriggerImpl(clock) and its siblings | clock, or TimeProvider.System when omitted |
| a job store reading it back | the clock that store's scheduler runs on |
For the last row:
RAMJobStorereturns 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 member | Does |
|---|---|
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 |
FindByIdtries, in order: the platform, a built-in alias table (UTC,CET,US/Easternand friends), IANA-to-Windows conversion, then registered resolvers. The platform goes first because converting first would rewriteUS/EasterntoEastern Standard Time, and a job store would write the rewritten id back intoTIME_ZONE_ID.- Resolvers are consulted most-recently-added first, and are process-wide, because
FindByIdis called where no scheduler is in scope (parsing a cron expression, deserializing a trigger from a blob).Quartz.Plugins.TimeZoneConverterinstalls 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,
PreserveHourOfDayAcrossDaylightSavingsandSkipDayIfHourDoesNotExist - 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
TryAddsemantics. The configuration callback runs first, soUseTimeProviderwins. - It does replace Quartz's own
TimeProvider.Systemfallback, so the key is never read and then ignored.
