Recurring Schedule
JobMaster provides a flexible system for recurring tasks. You can define schedules using:
- Time intervals (
TimeSpan) - Natural language expressions (NaturalCron)
- Standard cron syntax, via the
JobMaster.CronosorJobMaster.NCrontabpackages (see Cronos & NCrontab below)
Providers
The scheduler accepts compiled expressions from different providers (by TypeId):
- TimeSpanInterval — simple intervals like every N seconds/minutes/hours. Built in.
- NaturalCron — human-friendly schedules with rich rules and optional timezone. Built in.
- Cronos — standard cron syntax (5-field, or 6-field with seconds), via the
JobMaster.Cronospackage. - NCrontab — standard cron syntax (5-field, or 6-field with seconds), via the
JobMaster.NCrontabpackage.
Both dynamic (created at runtime) and static (code-defined profiles) are supported.
Dynamic Recurring Jobs
Dynamic schedules are tied to specific data (e.g., a specific subscription renewal or a per-user cleanup task).
// 1) Using the built-in TimeSpanInterval provider (runs every 5 minutes)
await scheduler.RecurringAsync<HelloJobHandler>(TimeSpan.FromMinutes(5));
// 2) Using NaturalCron via expression TypeId
var data = WriteableMessageData.New().SetStringValue("SubscriptionId", "sub_123");
await scheduler.RecurringAsync<RenewalHandler>(
NaturalCronExprCompiler.TypeId,
"every day between mon and fri at 18:00",
data: data);
// 3) Using NaturalCron fluent builder
var schedule = NaturalCronBuilder
.Every(30).Minutes()
.In(NaturalCronMonth.Jan)
.Between("09:00", "18:00")
.Build();
await scheduler.RecurringAsync<HelloJobHandler>(schedule);
Timezone handling (NaturalCron)
If your NaturalCron expression includes a timezone (IANA id) like in America/New_York, the engine computes occurrences in that zone. If no timezone is present, the cluster's configured IANA timezone is used.
Planning window
The planner generates occurrences within a moving horizon (configurable). Dates are produced strictly after the base time and on/before the planning horizon. End boundaries (endBefore) and start delays (startAfter) are respected.
Cronos & NCrontab
If you'd rather use standard cron syntax than NaturalCron's DSL, JobMaster offers two options, each its own package:
JobMaster.Cronos— wraps the Cronos library.JobMaster.NCrontab— wraps the NCrontab library.
Both support the same standard cron syntax — 5-field (* * * * *) or 6-field-with-seconds (* * * * * *), auto-detected by field count — so pick whichever you already know or already depend on elsewhere.
Unlike the built-in TimeSpanInterval/NaturalCron compilers, these ship as separate assemblies and aren't auto-discovered — register the one you want before AddJobMasterCluster (same registration invariant as any custom recurrence engine):
using JobMaster.Cronos; // or JobMaster.NCrontab
builder.Services.AddJobMasterCronos(); // or AddJobMasterNCrontab()
builder.Services.AddJobMasterCluster(config => { ... });
// Dynamic, via the convenience extension (equivalent to the TypeId+string overload above)
await scheduler.RecurringAsync<RenewalHandler>("0 18 * * 1-5", data: data); // 18:00 on weekdays
// Attribute-based static schedule
[CronosSchedule("*/5 * * * *")] // every 5 minutes
public sealed class InventorySyncHandler : IJobMasterHandler
{
public async Task HandleAsync(JobContext job) { ... }
}
[NCrontabSchedule("...")] works the same way for the NCrontab package.
Unlike NaturalCron/TimeSpanInterval, standard cron expressions are wall-clock grid-aligned — */5 * * * * fires at :00, :05, :10, ... of every hour, not 5 minutes after whenever the schedule was created.
The 6-field format makes it easy to reach for second-level precision (*/5 * * * * *) — before doing so, see Avoid Sub-minute Recurring Schedules.
Static Recurring Profiles (System Jobs)
For system-wide routines like backups or maintenance, define a StaticRecurringSchedulesProfile. These do not transport message data and are typically used for global background tasks.
public class MaintenanceProfile : IStaticRecurringSchedulesProfile
{
public static string ProfileId => "Maintenance";
public static void Config(RecurringScheduleDefinitionCollection collection)
{
collection
.Add<CleanupHandler>(TimeSpan.FromDays(1))
.Add<SyncHandler>(
NaturalCronExprCompiler.TypeId,
"every 1 hour between 09:00 and 18:00",
defId: "HourlySync");
}
}
Attribute-Based Static Schedules (No Profile Class)
For the common case of one handler with one (or a few) fixed schedules, a full profile class is more than you need — decorate the handler directly instead:
[NaturalCronSchedule("every 6 minutes")]
public sealed class InventorySyncHandler : IJobMasterHandler
{
public async Task HandleAsync(JobContext job) { ... }
}
No profile class, no Add<...>() call — it's discovered and registered automatically at cluster startup. [TimeSpanIntervalSchedule("...")] works the same way for interval-based schedules:
[TimeSpanIntervalSchedule("00:06:00")]
public sealed class InventorySyncHandler : IJobMasterHandler
{
public async Task HandleAsync(JobContext job) { ... }
}
A handler can carry more than one such attribute if it needs more than one schedule:
[NaturalCronSchedule("every day at 09:00")]
[NaturalCronSchedule("every day at 18:00")]
public sealed class TwiceDailyReportHandler : IJobMasterHandler
{
public async Task HandleAsync(JobContext job) { ... }
}
The schedule attribute itself only carries the recurrence expression — nothing else. Priority/timeout/retries/worker-lane still work the normal way: add the same handler-level attributes you'd use for a one-off job ([JobMasterPriority]/[JobMasterTimeout]/[JobMasterMaxNumberOfRetries]/[JobMasterWorkerLane], or a JobDefinitionConfigAttribute — see Publisher/Consumer Separation) and they're picked up automatically, re-resolved fresh on every occurrence rather than frozen in at registration:
[NaturalCronSchedule("every 6 minutes")]
[JobMasterPriority(JobMasterPriority.High)]
[JobMasterWorkerLane("Inventory")]
public sealed class InventorySyncHandler : IJobMasterHandler
really does run every occurrence at High priority on the Inventory lane.
The one thing this style can't do is target a specific cluster — there's no clusterId parameter on the schedule attribute at all, so it always registers to whichever cluster is the default one: the cluster you called .SetAsDefault() on, or, if you never called it and only configured one cluster, that lone cluster automatically. With more than one cluster configured and none marked default, use a IStaticRecurringSchedulesProfile instead — its ProfileId/Config give you a ClusterId to target explicitly.
Every compiler has its own attribute — the built-in NaturalCron/TimeSpanInterval, the first-party Cronos/NCrontab packages (see Cronos & NCrontab above), and any custom compiler registered via RecurrenceCompilerFactory.RegisterCompiler (see Custom Recurrence Engines below) can all supply their own RecurringScheduleAttribute subclass the same way.
Dynamic vs. Static: Comparison
| Feature | Dynamic Recurring | Static Profile |
|---|---|---|
| Primary Use Case | Per-entity logic (e.g., specific Subscription, User cleanup). | Global system routines (e.g., Database backup, Log rotation). |
| Data Payload | Supported. Can transport unique MsgData for each instance. | Not Supported. Handlers run without specific message data. |
| Where to Define | Enqueued at runtime via IJobMasterScheduler. | Defined in code by implementing IStaticRecurringSchedulesProfile. |
| Persistence | Stored and managed permanently in the Cluster Database. | Stored in the Cluster DB for monitoring, but automatically inactivated if the profile is removed from code. |
| Scalability | Can be created/deleted dynamically by your business logic. | Fixed at deployment time; requires a code change or profile update to modify. |
Statuses
Every recurring schedule (dynamic or static) has its own lifecycle, separate from the individual jobs it generates:
| Status | Description |
|---|---|
PendingSave | Created locally but not yet persisted to the Master DB. |
Active | Running and generating job occurrences. |
Canceled | Explicitly stopped before reaching its end date — terminal. |
Inactive | Paused, or has no remaining occurrences within its configured window — terminal. |
Completed | Reached its natural end (endBefore, or a finite expression) and will not generate further jobs — terminal. |
Canceled, Inactive, and Completed are all terminal — a schedule in any of these states is what DataRetentionTtl and Archiving mean by "terminated recurring schedules."
Configuration
Recurring schedules accept the same configuration options as one-off jobs — priority, worker lane, timeout, max retries, metadata, and clusterId.
var customMeta = WritableMetadata.New().SetStringValue("Source", "Scheduler");
await scheduler.RecurringAsync<HelloJobHandler>(
TimeSpan.FromMinutes(30),
metadata: customMeta,
priority: JobMasterPriority.Low,
workerLane: "BackgroundTasks"
);
Configuration provided in a IStaticRecurringSchedulesProfile or via RecurringAsync overrides attributes defined on the IJobMasterHandler class. This lets you reuse the same handler across different schedules with different priorities or lanes.
Sub-minute recurring schedules are supported, but keep in mind that jobs may fail due to execution overlaps and start delays of 10–20 seconds are expected in a distributed cluster. For tasks that need precise second-level execution, a .NET IHostedService or a dedicated background loop is a better fit.
Custom Recurrence Engines
If TimeSpanInterval, NaturalCron, Cronos, and NCrontab don't cover your needs (e.g. custom business rules), you can plug in your own engine by implementing two interfaces and registering the compiler — this is the same mechanism the Cronos/NCrontab packages themselves use internally.
1. Implement IRecurrenceCompiledExpr
This holds the parsed schedule and calculates the next occurrence.
public class MyCronCompiledExpr : IRecurrenceCompiledExpr
{
public string Expression { get; }
public string ExpressionTypeId => MyCronCompiler.TypeId;
private readonly CronExpression _cron; // your parsing library
public MyCronCompiledExpr(string expression, CronExpression cron)
{
Expression = expression;
_cron = cron;
}
public DateTime? GetNextOccurrence(DateTime dateTime, string ianaTimeZoneId)
=> _cron.GetNextOccurrence(dateTime);
public bool HasEnded(DateTime dateTime, string ianaTimeZoneId)
=> GetNextOccurrence(dateTime, ianaTimeZoneId) == null;
}
2. Implement IRecurrenceExprCompiler
This parses raw expression strings into compiled instances. The ExpressionTypeId is the key used everywhere in scheduling calls.
public class MyCronCompiler : IRecurrenceExprCompiler
{
public const string TypeId = "MyCron";
public string ExpressionTypeId => TypeId;
public IRecurrenceCompiledExpr? TryCompile(string expression)
{
var cron = CronExpression.TryParse(expression);
return cron is null ? null : new MyCronCompiledExpr(expression, cron);
}
public IRecurrenceCompiledExpr Compile(string expression)
=> TryCompile(expression) ?? throw new ArgumentException($"Invalid MyCron expression: {expression}");
}
3. Register at Startup
Call RegisterCompiler before AddJobMasterCluster. Built-in compilers are auto-discovered, but custom ones must be registered manually.
RecurrenceCompilerFactory.RegisterCompiler(new MyCronCompiler());
builder.Services.AddJobMasterCluster(config => { ... });
4. Use Your Custom TypeId
Once registered, use the TypeId anywhere you schedule a recurring job — dynamic or static:
await scheduler.RecurringAsync<MyHandler>(MyCronCompiler.TypeId, "0 18 * * 1-5");