Coming from TickerQ
This page maps TickerQ 10.4.0 (source at commit c6ed1e7d, which that release's packages name; there is no v10.4.0 tag) to Quartz.NET 4.1. To weigh the two, see Comparison.
The main difference: a TickerQ job is an attributed method, found by a source generator and scheduled by its string name; a Quartz job is a type, registered and scheduled by that type, so there is no name to keep in step with a method.
// [TickerFunction("cleanup", "0 */6 * * *")] on a method
services.AddQuartz(q =>
{
q.AddJob<CleanupJob>(j => j.WithIdentity("cleanup"));
q.AddTrigger<CleanupJob>(t => t
.ForJob("cleanup")
// NCrontab takes '*' in both day fields; Quartz wants '?' in one of them, and it
// reads six fields with seconds first.
.WithCronSchedule("0 0 0/6 * * ?"));
});
- Build-time cron checks carry over. TickerQ reports an unparseable expression as
TQ003; Quartz 4.2 does the same with its run-time parser. See Compile-Time Checks. - So do attributes.
[QuartzJob]and[CronTrigger]declare a job and schedule on the class; a source generator writes the registration. - A job can be a lambda, from 4.3.
q.ScheduleJob("cleanup", (IRepo repo, CancellationToken ct) => …, t => t.WithCronSchedule(…))takes its services as parameters, as a[TickerFunction]method does. See Delegate Jobs. - A schedule can skip the dialect, from 4.3.
t.WithCronSchedule(cron => cron.Every(TimeSpan.FromMinutes(5)))is*/5 * * * *without the six-field rewrite, andcron.AtTime(new TimeOnly(3, 0)).OnWeekdays()is0 3 * * 1-5. See Cron Triggers. - An expression built at run time is checked at run time. Build one with
CronExpressionBuilder, or check one by asking the trigger when it fires.
The API, side by side
| TickerQ | Quartz.NET | Difference |
|---|---|---|
[TickerFunction("name")] on a method | a class implementing IJob, registered with q.AddJob<T>(…) or with [QuartzJob] on the class; from 4.3, a lambda with q.AddJob("name", …) | the schedule names the class, or the lambda's key |
[TickerFunction("name", "*/5 * * * *")] | q.AddTrigger<T>(t => t.WithCronSchedule(…)), [CronTrigger("0 0/5 * * * ?")] on the class, or from 4.3 q.ScheduleJob("name", lambda, t => t.WithCronSchedule(…)) | the schedule is its own trigger, so one job can have several |
[TickerFunction("name", "%Jobs:Cleanup%")], an expression from IConfiguration | from 4.3, [CronTrigger("0 0 0/6 * * ?", ConfigurationKey = "Jobs:Cleanup")] — see A schedule from configuration | the literal is the fallback and is checked at build time; a configured value that does not parse stops the host |
new TimeTickerEntity { Function = "name", ExecutionTime = … } | scheduler.ScheduleJob<TJob, TInput>(input, at) | |
timeTicker.AddAsync<WelcomeJob>(executionTime) | the same call | both typed; Quartz's also carries the payload type |
new CronTickerEntity { Expression = … } | a cron trigger through TriggerBuilder | |
RunCondition on a child ticker | .StartAfter(parentTriggerKey, condition) on the follow-up's trigger | see Chaining |
Retries + RetryIntervals = [30, 120, 600] | RetryPolicy.Explicit(…) on the trigger | the wait is held in the store, not the process |
maxConcurrency on [TickerFunction] | an execution group and a limit | the limit can be cluster-wide |
TickerTaskPriority | Priority on the trigger | TickerQ's orders dispatch per function; Quartz's breaks ties between triggers due together |
TickerFunctionContext | IJobExecutionContext | |
TerminateExecutionException | return normally, or throw JobExecutionException with an unschedule instruction |
A one-shot ticker is the overload taking a payload and a time. SendWelcomeEmailJob is an IJob<string>, so the payload type is checked at the call site:
// await timeTicker.AddAsync(new TimeTickerEntity { Function = "send-welcome", ... })
await scheduler.ScheduleJob<SendWelcomeEmailJob, string>(
"[email protected]",
TimeSpan.FromSeconds(30),
cancellationToken: cancellationToken);
Configuration
| TickerQ | Quartz.NET |
|---|---|
builder.Services.AddTickerQ(opt => …) | builder.AddQuartz(q => …) |
app.UseTickerQ() | builder.AddQuartzHostedService() |
app.UseTickerQ(TickerQStartMode.Manual) | builder.AddQuartzHostedService(o => o.AutoStart = false), then scheduler.Start() |
s.MaxConcurrency = 16 | q.UseDefaultThreadPool(maxConcurrency: 16) |
s.SchedulerTimeZone = … | InTimeZone(…) on each trigger — there is no scheduler-wide zone |
s.NodeIdentifier = … | QuartzSchedulerOptions.InstanceId, and PreferredNode to pin a trigger to a node |
s.MinPollingInterval | IdleWaitTime — a ceiling, not a floor: see below |
s.FallbackIntervalChecker | the misfire handler, whose cadence follows MisfireThreshold |
SkipStaleCronOccurrencesOnStartup() | the DoNothing misfire instruction, per trigger |
opt.AddOperationalStore(ef => …) | q.UsePersistentStore(s => s.UsePostgres(cs)) — ADO.NET, not EF Core |
opt.AddDashboard() | services.AddQuartzDashboard() + app.MapQuartzDashboard(), plus an authorization decision |
opt.AddOpenTelemetryInstrumentation() | AddSource(QuartzInstrumentation.ActivitySourceName) and AddMeter(QuartzInstrumentation.MeterName) — no package needed |
opt.WithJsonContext(AppJsonContext.Default) | store.UseSystemTextJsonSerializer(r => r.AddTypeInfoResolver(…)) — Publishing Trimmed and Native AOT |
The differences that bite
The cron dialect is not the same one
| TickerQ (NCrontab) | Quartz (reference) | |
|---|---|---|
| Fields | six, seconds first; or five, expanded with a 0 seconds field | six or seven |
| Special characters | *, ,, -, /, digits and names | those, plus L, W, # and H |
| Day fields | * in both | one of the two must be ? (the two are a union) |
*/5 * * * * *becomes*/5 * * * * ?; five-field0 */6 * * *becomes0 0 0/6 * * ?.- A five-field expression in the default format is an error naming the rewrite, not a silently different schedule.
CronFormat.Unixreads it as written.
There is no polling floor
TickerQ's MinPollingInterval (one second by default) is the minimum pause between database polls, so it bounds finer schedules. Quartz's IdleWaitTime (thirty seconds by default) is a ceiling: the thread waits until the earliest trigger due within it, and a schedule change wakes it early. A seconds-level cron fires at its stated cadence, including one TickerQ was rounding up. See What the loop costs while it waits.
Time zones are per trigger, and the default is the machine's
Both default to the machine's zone (TickerQ's SchedulerTimeZone, Quartz's TimeZoneInfo.Local per cron trigger), but Quartz has no scheduler-wide setting. Set InTimeZone(…) explicitly on every trigger.
A missed occurrence is a decision, not a late run
TickerQ's fallback sweep runs an overdue row late, unless SkipStaleCronOccurrencesOnStartup() drops occurrences older than its threshold. Quartz decides per trigger: FireAndProceed, IgnoreMisfires or DoNothing. The default SmartPolicy is FireAndProceed for cron (one catch-up, then on schedule). DoNothing is the nearest to SkipStaleCronOccurrencesOnStartup, set per trigger instead of by a start-up sweep.
Retries wait in the store, not in the worker
TickerQ retries inside the execution with a Task.Delay between attempts, holding the worker slot and the row's lease, and ending with the process. A Quartz retry is a new fire time in the store:
// Retries = 3, RetryIntervals = [30, 120, 600]
services.AddQuartz(q =>
{
q.AddJob<CleanupJob>(j => j.WithIdentity("cleanup"));
q.AddTrigger<CleanupJob>(t => t
.ForJob("cleanup")
.WithCronSchedule("0 0 0/6 * * ?")
.WithRetryPolicy(RetryPolicy.Explicit(
TimeSpan.FromSeconds(30),
TimeSpan.FromMinutes(2),
TimeSpan.FromMinutes(10))));
});
- The slot is released between attempts, a retry survives a node dying, and any node can run it.
- Intervals are
TimeSpans, not integers. - A retry that would land at or after the next occurrence is dropped: a ten-minute wait on a five-minute schedule does nothing. See Retrying Failed Jobs.
- When attempts run out, TickerQ marks the ticker failed; Quartz returns the trigger to its schedule and reports it through
ITriggerListener.TriggerRetriesExhausted, a log event, a counter and a final history row. From 4.3,PauseTriggerWhenRetriesExhausted()stops it instead, paused with the error as the reason, which is closer to TickerQ's failed ticker.
Concurrency has two settings, and one of them can be the cluster's
| Limit | TickerQ | Quartz |
|---|---|---|
| This process | MaxConcurrency in ConfigureScheduler | the thread pool size |
| One function or category | maxConcurrency on [TickerFunction], a SemaphoreSlim in the process | an execution group limit, optionally counted across every node sharing the store |
services.AddQuartz(q =>
{
// s.MaxConcurrency = 16 — how much this process runs at once
q.UseDefaultThreadPool(maxConcurrency: 16);
// maxConcurrency on [TickerFunction] — how much of one category runs at once, except
// that the scope is yours to choose
q.UseExecutionLimits(limits =>
{
limits.ForGroup("cleanup", maxConcurrent: 1, ExecutionLimitScope.Cluster);
});
});
[DisallowConcurrentExecution] is narrower: one firing per job key at a time, cluster-wide with a persistent store. From 4.3 an overlap policy decides it per trigger: skip, buffer one, or cancel the running firing.
The dashboard will not start until you say who may reach it
TickerQ's dashboard defaults to AuthMode.None. Quartz's fails before the web host binds a listener if the mapping has neither RequireAuthorization nor AllowAnonymous and the host has no fallback policy. Add one call: app.MapQuartzDashboard().RequireAuthorization(…) after AddQuartzDashboard(). See Production hardening for read-only mode and the job type allow-list.
Chaining and its condition
TickerQ's RunCondition becomes a continuation: StartAfter(parentTriggerKey, condition) on the follow-up's trigger, held by the store in TriggerState.Awaiting and released inside the parent's transaction. The parent is a trigger's firing, not a row.
TickerQ RunCondition | Quartz ContinuationCondition |
|---|---|
OnSuccess | OnSuccess |
OnFailure | OnFailure |
OnCancelled | OnCancellation |
OnFailureOrCancelled | OnFailure and OnCancellation combined; the enum is flags |
OnAnyCompletedStatus | OnAnyOutcome |
InProgress | none: a continuation is released by an outcome, and a running firing has none |
| — | OnVeto, for a firing an ITriggerListener refused |
TickerQ's chaining is TimeTicker-only; JobChainingJobListener works on job keys, so a cron trigger's job can have a follow-up.
The store is ADO.NET, not EF Core
TickerQ's AddOperationalStore(ef => …) adds tables to your DbContext. Quartz uses its own tables (QRTZ_-prefixed, prefix configurable) with no DbContext or EF migration; create them with ProvisionSchema() or the script for your database. A schedule change is not part of SaveChanges; for one transaction with your data, use an ambient transaction.
Running both while you move
They are separate hosted services on separate schemas, so they can share a host. A recurring job registered in both fires twice, unreported: remove it from one before adding it to the other.
See also
- Comparison — the two weighed against each other, sourced
- One-Off Job — the
ScheduleJob<TJob, TInput>overloads in full - Retrying Failed Jobs — the retry policy and its rules
- Cron Expression Reference — the dialect, and how to check an expression
- Publishing Trimmed and Native AOT — the equivalent of
WithJsonContext
