# 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.

## 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](/docs/core-concepts/jobs-and-handlers) and [Job Lifecycle](/docs/core-concepts/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.

```csharp title="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.

```csharp title="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.

```text
*/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.

```text
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`.

```csharp title="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.

```csharp title="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.

```csharp title="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.

```csharp title="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.

```csharp title="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.

```csharp title="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.

```csharp title="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](/docs/guides/configure-retries-and-error-handling), not from the schedule.

## Where to go next

- [Define and Enqueue a Job](/docs/guides/define-and-enqueue-a-job) to see the Job and Handler shape a schedule mints.
- [Cron API reference](/docs/reference/cron-api) for the exact `Cron` and `CronExpression` surface.
- [Configure Retries and Error Handling](/docs/guides/configure-retries-and-error-handling) for how minted instances behave when they fail.
- [Operator actions](/docs/dashboard-operations/overview#what-operators-can-do) for trigger, pause, and the audit trail.
- [Scheduling](/docs/core-concepts/scheduling) for how due times move through the pipeline.
- [Virtual Time](/docs/testing/behavior-over-time) for testing schedules deterministically.
