Cron API

The cron builder and expression type behind Recurring Schedules, covering the fluent helpers, the accepted grammar, the canonical stored form, and IANA time-zone evaluation.


A Recurring Schedule fires on a cron expression. BackWave models that expression as a CronExpression value rather than a bare string, and gives you two ways to make one: the fluent Cron builder for common shapes, and CronExpression.Parse for a raw cron string. Either way you end up with the same type, and the schedule stores a single canonical six-field form. This page is the reference for both entry points, the grammar Parse accepts, how the canonical form is derived, and how a schedule evaluates its cron in UTC or an IANA time zone.

Both Cron and CronExpression live in the BackWave.Core namespace.

The Cron builder#

Cron is a static factory whose methods each return a CronExpression. Every builder compiles down to a cron expression, so a builder can only express what cron itself can, and the canonical cron form is what gets stored either way. Reach for a builder when your schedule fits one of the common shapes below; drop to CronExpression.Parse for anything they do not cover.

MethodSignatureFires
Every minuteCron.EveryMinute()At the start of every minute.
Every N minutesCron.EveryMinutes(int minutes)Every minutes minutes. minutes must be at least 1.
HourlyCron.Hourly(int atMinute = 0)Once an hour, at atMinute (0–59, default 0).
DailyCron.Daily(int hour, int minute = 0)Once a day, at hour (0–23) and minute (0–59, default 0).
WeeklyCron.Weekly(DayOfWeek day, int hour, int minute = 0)Once a week, on day, at hour (0–23) and minute (0–59, default 0).
MonthlyCron.Monthly(int dayOfMonth, int hour, int minute = 0)Once a month, on dayOfMonth (1–31), at hour (0–23) and minute (0–59, default 0).
BuilderExamples.cs
CronExpression everyFive = Cron.EveryMinutes(5);        // "*/5 * * * *"
CronExpression nightly   = Cron.Daily(hour: 2);         // "0 2 * * *"
CronExpression topOfHour = Cron.Hourly(atMinute: 15);   // "15 * * * *"
CronExpression mondays   = Cron.Weekly(DayOfWeek.Monday, hour: 9); // "0 9 * * 1"
CronExpression firstOfMonth = Cron.Monthly(dayOfMonth: 1, hour: 14, minute: 30); // "30 14 1 * *"

A few things to know about the builders:

  • Each builder validates its arguments and throws FormatException when a value falls outside the range shown above. Cron.EveryMinutes(0), for example, throws, which is why the argument must be at least 1.
  • Weekly takes a System.DayOfWeek, where DayOfWeek.Sunday is 0 and DayOfWeek.Saturday is 6.
  • Monthly does not clamp a day past the end of a short month. A schedule pinned to day 31 simply does not fire in a month that has no 31st; it is skipped that month, not moved to the last day.
  • The builders cover minute-and-coarser schedules only. There is no builder for a seconds field, arbitrary ranges, or lists. Those require a raw string through CronExpression.Parse.

Parsing a raw expression#

CronExpression.Parse turns a cron string into a CronExpression. It is the only public constructor for the type; instances come from Parse or from a Cron builder, never from new.

ParseExample.cs
CronExpression cron = CronExpression.Parse("0 30 9 * * *"); // 09:30 every day

Parse accepts standard cron in two field counts and rejects everything else:

FieldsInterpretation
5minute hour day-of-month month day-of-week. A 0 seconds field is prepended for you.
6second minute hour day-of-month month day-of-week.

Any other field count throws FormatException. Runs of extra spaces between fields are tolerated. Passing a null, empty, or whitespace string throws ArgumentException.

The six canonical fields, in order, and the value each accepts:

FieldPositionRange
seconds1st (6-field form only)0–59
minutes2nd0–59
hours3rd0–23
day-of-month4th1–31
month5th1–12
day-of-week6th0–7

Field syntax#

Within a field, comma-separated parts are combined, so a value matches if it matches any part. Each part supports:

FormMeaning
*The whole range for that field.
aA single value.
a-bAn inclusive range from a to b.
*/sThe whole range, stepped by s.
a-b/sThe range a to b, stepped by s.
a/sFrom a to the field's maximum, stepped by s.
a,b,cA list; each element may itself be any of the forms above, for example 1-5,10 or 0,15,30,45.
SyntaxExamples.cs
CronExpression quarterHour = CronExpression.Parse("0,15,30,45 * * * *");
CronExpression businessHours = CronExpression.Parse("0 9-17 * * *");
CronExpression everyTenSeconds = CronExpression.Parse("*/10 * * * * *"); // 6-field

A part is out of range, and throws FormatException, when its low bound is below the field minimum, its high bound is above the field maximum, its low bound exceeds its high bound, or its step is less than 1. A reversed range such as 5-2 throws, and a step of 0 throws.

What Parse does not accept#

BackWave parses standard numeric cron only, with no Quartz extensions. The following all throw FormatException:

  • Names. Month names (JAN, FEB) and day names (SUN, MON) are not recognized. Fields are numbers; a non-numeric token is rejected.
  • Quartz tokens. ?, L, W, #, and LW are not supported.
  • Named macros. @daily, @hourly, @reboot, and similar shorthands are not supported.

Use the numeric equivalents: a weekday is 1 through 5, and Sunday is 0 (or 7).

The canonical form#

CronExpression.Canonical is the six-field string a schedule stores. It is the single stored representation regardless of which idiom defined the schedule, so a builder and an equivalent raw string persist identically.

Canonical.cs
CronExpression.Parse("0 2 * * *").Canonical;   // "0 0 2 * * *"
CronExpression.Parse("30 0 2 * * *").Canonical; // "30 0 2 * * *"
Cron.Daily(2).Canonical;                        // "0 0 2 * * *"

Canonicalization is deliberately minimal. It guarantees two things and no more:

  • The result always has six fields. A five-field input gets a 0 seconds field prepended; the other five fields are copied verbatim.
  • Fields are separated by single spaces, so extra whitespace in the input does not survive.

It does not rewrite field text. Steps and ranges are not expanded (*/15 stays */15, 1-5 stays 1-5), and a 7 for Sunday is not rewritten to 0. Two expressions that mean the same thing but are written differently, such as 0 * * * * and 0-0 * * * *, produce different canonical strings. Because this string is both the value the schedule stores and the key BackWave parses and caches the expression under, prefer writing an expression one consistent way.

Day-of-month and day-of-week#

The day-of-month and day-of-week fields interact by the standard cron rule:

  • When both fields are restricted, meaning neither is *, a day matches if it matches either field. 0 0 13 * 5 fires on the 13th of the month or on any Friday.
  • When only one of the two is restricted, that field alone decides the day.

The restriction test is a literal check for *. A day-of-month field written as 1-31 counts as restricted even though it spans every day, so pairing it with a restricted day-of-week field switches on the "either" rule. To keep the plain "and" behavior, leave the field you do not want to constrain as *.

Sunday is both 0 and 7 in the day-of-week field, so ... * * 0 and ... * * 7 are equivalent.

Next occurrence#

NextAfter computes the first firing strictly after a given instant. It is the same "next due tick" calculation a schedule uses to decide what to mint.

NextAfter.cs
CronExpression cron = CronExpression.Parse("*/15 * * * *");
DateTimeOffset? next = cron.NextAfter(DateTimeOffset.UtcNow);
PropertyBehavior
SignatureDateTimeOffset? NextAfter(DateTimeOffset after)
EvaluationIn UTC. The after argument is converted with ToUniversalTime before the search.
ResultThe first occurrence strictly later than after; never equal to it.
GranularityOne second when the expression has a seconds field, otherwise one minute.
No occurrenceReturns null when nothing fires within four years of after.

A null result is not an error. It means the expression is effectively unsatisfiable, such as one pinned to an impossible date like February 30 (0 0 30 2 *). Handle null where you call NextAfter directly.

Time zones and daylight saving#

A cron evaluates in UTC by default. Pass an IANA time-zone id to the schedule, such as "America/New_York", and its occurrences are computed against that zone's local wall-clock time. A null time zone means UTC.

ZonedSchedule.cs
await client.UpsertRecurringAsync(
    "nightly-report",
    Cron.Daily(hour: 2),
    new GenerateReport(),
    timeZone: "America/New_York");

Across daylight-saving boundaries the schedule follows fixed rules, and there is no knob to change them:

TransitionRule
Spring forward (a local time that is skipped)The tick fires once, at the first valid instant after the gap. A 02:30 daily job in a zone that jumps 02:00 to 03:00 fires at 03:00 that day.
Fall back (a local time that occurs twice)The tick fires on its first occurrence only, not the repeat. A nightly job in the ambiguous hour runs once, not twice.

The effect is that a daily schedule fires exactly once per day through both transitions.

The time-zone id must resolve on the host running the schedule. An id that is present in one environment but missing in another, or a typo, is a common cause of a schedule that works in development and fails in production. UpsertRecurringAsync checks the zone when you define the schedule and throws ArgumentException at that call if it cannot be resolved, so the failure surfaces loudly at the door rather than silently later.

Passing a cron to a schedule#

UpsertRecurringAsync takes the cron argument as a CronExpression, not a string. Pass a Cron builder result directly, or wrap a raw string in CronExpression.Parse. There is no string-cron overload.

Upsert.cs
// Builder.
await client.UpsertRecurringAsync("hourly-sync", Cron.Hourly(), new SyncCatalog());
 
// Raw expression.
await client.UpsertRecurringAsync(
    "seconds-heartbeat",
    CronExpression.Parse("*/10 * * * * *"),
    new Heartbeat());

When the schedule is stored, it keeps the cron's Canonical string and the time-zone id. That canonical six-field string is the single persisted representation of when the schedule fires. Because cron is already a parsed CronExpression by the time it reaches the client, the only thing that can fail validation at the call is an unresolvable time-zone id.

The Scheduling page covers the rest of UpsertRecurringAsync, including the scheduleId, the now starting instant, and the Catch-Up and No-Overlap policies that govern missed and overlapping runs.

Where to go next#

  • Scheduling: Due Time, UpsertRecurringAsync, and how minted jobs become runnable.
  • Schedule Recurring Jobs: a worked walkthrough of defining a schedule, Catch-Up, and No-Overlap.
  • Client API: the full signature of UpsertRecurringAsync and the rest of the client surface.
  • Limits & Defaults: the maintenance interval that bounds how quickly a due tick is minted, and other defaults in one place.

Found a problem on this page? Report an issue