Schedule Recurring Jobs

Create cron-driven Recurring Schedules with catch-up and no-overlap policies and time zones, then pause, resume, trigger, or remove them.


Create cron-driven Recurring Schedules with catch-up and no-overlap policies and time zones, then pause, resume, trigger, or remove them.

What a Recurring Schedule actually is#

A Recurring Schedule is a stored template, not a Job. It carries a cron expression, a payload, a Queue, and a few policies, and it mints Scheduled Job instances as time moves forward. The schedule and the Jobs it mints are separate things with separate lifecycles. Removing the schedule does not touch Jobs it already minted, and a minted Job knows nothing about future ticks.

That separation matters when you reason about behavior. Defining a schedule never runs work directly. It records intent. Each Worker node polls, looks at where the schedule's Cursor sits, walks the cron forward to the present, and decides which ticks to mint. The decision is computed by a pure function of the schedules and the current time, with no I/O and no wall-clock reads, which is why a year of time-zone behavior can be tested in milliseconds.

Two nodes can poll at the same instant without double-minting. Each mint is fenced by a compare-and-set on the Cursor, so a tick is minted exactly once across the whole cluster.

If you are new to how a minted Job flows through claim, Lease, and retry, read Jobs and Handlers and Job Lifecycle first. Everything below sits on top of that pipeline.

Define a schedule#

You create and update schedules with one method, UpsertRecurringAsync. There is no separate create call. Re-using the same scheduleId redefines the schedule in place.

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

The first argument is an id you own. The second is a parsed CronExpression, not a string. The third is a template instance of the Job you want minted. Everything after that is optional.

Note the cron parameter type. You pass either a Cron.* helper or CronExpression.Parse(...). A bare string will not compile.

Program.cs
// Both of these are valid:
await client.UpsertRecurringAsync("nightly-sync", Cron.Daily(2), new NightlySync("crm"));
await client.UpsertRecurringAsync("nightly-sync", CronExpression.Parse("0 2 * * *"), new NightlySync("crm"));

When you upsert, the Cursor is set to "now" (the injected clock, or the now you pass). Only ticks after that instant mint. There is no backfill. A schedule you define at noon will not mint the morning's missed runs, because as far as the schedule is concerned, those ticks happened before it existed.

The template payload is captured once#

The template you pass is serialized at upsert time and copied into every minted instance. It is not re-evaluated per tick. If your payload needs the current date or a fresh value, compute that inside the Handler, not in the template object.

Options and defaults#

Beyond the scheduleId, cron, and template you pass positionally, UpsertRecurringAsync takes a handful of optional arguments. The defaults: now is the injected clock, queue is the Job type's registered Queue, timeZone is UTC, catchUp is Skip, and noOverlap is false. The call returns a ValueTask with no value. You supply the id; nothing is handed back.

Cron expressions#

BackWave parses standard cron with one extension: an optional leading seconds field. A 5-field expression (min hour dom month dow) is accepted and stored as 6 fields by prepending 0 seconds. A 6-field expression puts seconds first.

*/15 * * * *      every 15 minutes
0 2 * * *         daily at 02:00
30 14 1 * *       14:30 on the 1st of each month
0 9 * * 1         Mondays at 09:00
*/10 * * * * *    every 10 seconds (6-field form)

Field ranges are seconds 0 to 59, minutes 0 to 59, hours 0 to 23, day-of-month 1 to 31, month 1 to 12, and day-of-week 0 to 7 where both 0 and 7 mean Sunday. Each field accepts *, lists like 1,15,30, ranges like 9-17, and steps like */n or 9-17/2.

What is not supported is worth memorizing, because the parser rejects it loudly rather than guessing:

  • No named macros. @daily, @hourly, and @reboot throw FormatException.
  • No textual names. JAN, MON, and SUN are not parsed. Use numbers.
  • No Quartz extensions. L, W, #, and ? are not recognized.

Malformed expressions throw at parse time, which is when you call Cron.* or CronExpression.Parse. A wrong field count (* * * *), an out-of-range value (61 * * * *), a non-numeric field (a * * * *), or a reversed range (5-2 * * * *) all throw FormatException. A null or empty string throws ArgumentException.

The day-of-month / day-of-week OR rule#

This trips people up, so it is worth stating plainly. When both the day-of-month and day-of-week fields are restricted (neither is *), a date matches if either one matches. This is standard cron OR semantics, not AND.

0 0 13 * 5        midnight on the 13th OR on any Friday

If only one of the two day fields is restricted, the match is the obvious AND.

Fluent helpers#

The Cron static class builds expressions without writing raw strings. Each helper compiles down to a CronExpression.

Program.cs
Cron.EveryMinute();              // * * * * *
Cron.EveryMinutes(5);            // */5 * * * *
Cron.Hourly(atMinute: 30);       // 30 * * * *
Cron.Daily(hour: 2);             // 0 2 * * *
Cron.Weekly(DayOfWeek.Monday, hour: 9);
Cron.Monthly(dayOfMonth: 1, hour: 0);

One sharp edge on Monthly: a day past a short month's end does not fire that month. Monthly(31, ...) skips February entirely. It does not roll back to the 28th.

Time zones and DST#

A schedule with no timeZone runs in UTC. Pass an IANA zone id, such as "America/New_York", to anchor ticks to local wall-clock time. The zone is validated at the door: if the id will not resolve on the host, UpsertRecurringAsync throws ArgumentException (parameter name timeZone) and stores nothing.

Daylight saving transitions are handled with two fixed rules:

  • Spring forward, where a local time is skipped, fires once at the first valid instant after the gap. A 0 2 * * * schedule in New York on the 2026 spring-forward date fires at 03:00 EDT, because 02:00 never existed that day.
  • Fall back, where a local time happens twice, fires only the first occurrence. The repeated second hour is not minted. The tick does not double-fire.

A daily 2am New York schedule fires exactly 365 times across 2026. The DST days do not add or drop a run.

Zones that exist in dev but not in prod#

The IANA zone has to exist on the host doing the minting. A zone present in your laptop's database but missing from a slim container image makes that schedule a poisoned row. BackWave does not fail-stop the Worker Group over it. The mint planner skips the unresolvable schedule, healthy schedules keep minting, and the Monitor surfaces the bad one with its Error set and NextDue null. The failure is visible and isolated, never silent.

Catch-Up Policy: what happens after an outage#

If your cluster is down when a tick is due, the Cursor falls behind. When polling resumes, the planner walks forward and finds ticks it missed. What it does with them is the Catch-Up Policy.

A tick resolved within one minute of now is treated as merely late (normal poll granularity) and mints under both policies. A tick older than that is "missed," and the policy decides its fate.

CatchUpPolicy.Skip (the default) does nothing for missed ticks. It records them as skipped, jumps the Cursor past them, and moves on. Missed means missed. Those occurrences are never revisited.

Program.cs
await client.UpsertRecurringAsync(
    "hourly-rollup", Cron.Hourly(), new Rollup(), catchUp: CatchUpPolicy.Skip);

CatchUpPolicy.Coalesce mints exactly one make-up run, for the latest missed tick, and records the earlier ones as skipped. It is a single catch-up, not a replay.

Program.cs
await client.UpsertRecurringAsync(
    "hourly-rollup", Cron.Hourly(), new Rollup(), catchUp: CatchUpPolicy.Coalesce);

There is no mode that replays every missed occurrence. If you were down for six hours of an hourly schedule, Coalesce runs once, not six times. Replaying the full backlog is deliberately left out, because the common need after an outage is one fresh run, not a flood.

Each poll resolves at most 32 ticks per schedule, so a very long outage drains across several polls rather than in one burst.

No-Overlap: don't stack instances#

Set noOverlap: true and a tick will not mint while a previous instance of the same schedule is still live (any non-terminal state). The skipped tick is recorded visibly on the schedule, the same as any other skip, so you can see that a run was suppressed and why.

Program.cs
await client.UpsertRecurringAsync(
    "long-sync", Cron.EveryMinute(), new LongSync(), noOverlap: true);

This behaves like a per-schedule concurrency limit of one, enforced at mint time. If a run takes nine minutes and the schedule fires every minute, you get one run going, not a pile-up of eight more waiting behind it. While catching up, at most one instance mints per batch under this policy.

When there is no live instance, the tick mints normally.

Observe schedules and their minted Jobs#

BackWaveMonitor.ListSchedulesAsync returns every schedule with its Cursor, the next due tick, and recently skipped ticks.

Program.cs
var statuses = await monitor.ListSchedulesAsync();
foreach (var s in statuses)
{
    Console.WriteLine($"{s.ScheduleId}: next due {s.NextDue}, {s.SkippedTicks.Count} skipped");
}

Each ScheduleStatus carries ScheduleId, the canonical Cron, WireName, Queue, TimeZoneId, CatchUp, NoOverlap, Cursor, NextDue, HasLiveInstance, SkippedTicks, and Error. NextDue is the next tick after the settled Cursor, and it is null when the schedule cannot resolve on this host or has no future occurrence. A non-null Error means the schedule is unresolvable here.

To find the Jobs a schedule has minted, query Jobs by schedule id. Each minted instance carries its ScheduleId, so they are traceable back to the template that produced them.

Trigger, pause, and remove#

Trigger a run now#

BackWaveOperator.TriggerScheduleNowAsync mints exactly one instance due now, on demand. It does not move the Cursor and does not disturb future ticks. It is purely additive: a one-off run alongside the normal schedule.

Program.cs
var result = await op.TriggerScheduleNowAsync(scheduleId, actor: "ops@example.com");
// result is TriggerScheduleResult.Triggered, or .ScheduleNotFound for an unknown id

Every Operator Action is stamped with an actor and recorded in the append-only audit log. You can read the trail for a schedule with ListAuditRecordsAsync, passing the schedule id as the target.

"Pausing" a schedule#

There is no pause or resume for a schedule, and no enabled flag on the record. Those operations do not exist on the schedule surface.

What does exist is pausing a Queue. PauseQueueAsync and ResumeQueueAsync act on a Queue cluster-wide, which holds back the lane a schedule's Jobs run on, but it is a Queue operation, not a schedule operation. If your goal is to stop a schedule from minting, the answer is to remove it.

Remove a schedule#

RemoveRecurringAsync stops further minting. Instances already minted run to completion, untouched. Removing an id that does not exist is a no-op.

Program.cs
await client.RemoveRecurringAsync("nightly-report");

A few things that surprise people#

Redefining a schedule preserves its Cursor. Upserting an existing id with a stale now does not rewind it or re-mint already-resolved ticks. The cron, payload, and policies update in place; the Cursor keeps moving forward from where it was.

Defining a schedule never backfills. Only ticks after the Cursor mint, full stop.

Trigger-now is additive and leaves the Cursor alone. It does not skip the schedule ahead or consume a future tick.

Schedules carry no retry or attempt configuration. A minted instance follows the normal Job pipeline, so its retries come from the Job's Retry Policy, not from the schedule.

Where to go next#