Skip to main content

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.Cronos or JobMaster.NCrontab packages (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.Cronos package.
  • NCrontab — standard cron syntax (5-field, or 6-field with seconds), via the JobMaster.NCrontab package.

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:

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.

Always the default cluster

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.

Custom recurrence engines

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

FeatureDynamic RecurringStatic Profile
Primary Use CasePer-entity logic (e.g., specific Subscription, User cleanup).Global system routines (e.g., Database backup, Log rotation).
Data PayloadSupported. Can transport unique MsgData for each instance.Not Supported. Handlers run without specific message data.
Where to DefineEnqueued at runtime via IJobMasterScheduler.Defined in code by implementing IStaticRecurringSchedulesProfile.
PersistenceStored and managed permanently in the Cluster Database.Stored in the Cluster DB for monitoring, but automatically inactivated if the profile is removed from code.
ScalabilityCan 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:

StatusDescription
PendingSaveCreated locally but not yet persisted to the Master DB.
ActiveRunning and generating job occurrences.
CanceledExplicitly stopped before reaching its end date — terminal.
InactivePaused, or has no remaining occurrences within its configured window — terminal.
CompletedReached 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 override

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 schedules

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");