Scheduling
Time-based work: every job is really a Due Time, plus future scheduling and cron-driven Recurring Schedules.
Every job in BackWave carries a Due Time, the instant it becomes eligible to run. That is the only shape of work the system understands. "Enqueue now" is not a separate feature, it is a Due Time equal to the current instant. A future Due Time simply holds the job back until that instant arrives. Both travel the same code path, so there is one mechanism to learn and two ergonomic entry points into it: EnqueueAsync for a single job and UpsertRecurringAsync for a cron-defined schedule that mints jobs over time.
Due Time, the universal mechanism#
EnqueueAsync takes the Due Time as a required, positional argument. You always name when the job becomes eligible. There is no separate "delay" or "schedule-in" overload, because deferral is already expressed by the value you pass.
// Run as soon as a Worker is free: Due Time is now.
var jobId = await client.EnqueueAsync(new SendReceipt(orderId), DateTimeOffset.UtcNow);
// Defer: the same call, a future Due Time. The job waits until then.
await client.EnqueueAsync(new SendReminder(orderId), DateTimeOffset.UtcNow.AddHours(24));A future Due Time always defers a job. It is never rounded up and treated as due now. Until that instant passes, the job sits in storage and is invisible to Workers, then becomes claimable like any other due job.
Scheduling a one-off in the future#
EnqueueAsync returns the new job's Guid id, which you can keep to track the job or to wire it as a parent in a Dependency. Two optional arguments shape where the job lands:
| Argument | Effect when omitted | Effect when supplied |
|---|---|---|
queue | The job runs on its job type's registered Queue. | Routes this one job to the named Queue instead. |
tags | The job carries its type's default Tags. | Merges additively on top of the default Tags. |
EnqueueAsync also accepts a transaction, which commits the job atomically with your own database writes. That is Transactional Enqueue, and the Transactional Enqueue guide owns the mechanics. For scheduling, the point is only that the Due Time argument behaves identically whether or not a transaction is present.
Recurring schedules#
A Recurring Schedule is a cron-defined template that mints new jobs as time passes. The schedule is a distinct thing from the jobs it produces: it has its own identity and lifecycle, and each time it fires it mints a fresh job carrying a copy of the template payload.
await client.UpsertRecurringAsync(
"nightly-report",
Cron.Daily(hour: 2),
new GenerateReport(),
timeZone: "America/New_York");The first argument, scheduleId, is a stable string you choose. Calling UpsertRecurringAsync again with the same scheduleId redefines that schedule in place. This is an upsert, so it is idempotent and safe to call on every application startup. Schedules are registered imperatively against the client, typically in your startup code. There is no declarative or attribute-based registration to wire up.
A schedule starts watching from the moment it is defined. It mints only future ticks and never back-fills occurrences that would have fired before it existed. The optional now argument sets that starting instant explicitly, which is useful in tests.
To stop a schedule, call RemoveRecurringAsync with its scheduleId. Minting stops, but jobs already minted are left untouched and run to completion. Removing a scheduleId that does not exist is a no-op.
await client.RemoveRecurringAsync("nightly-report");Defining the cron#
The cron argument is a CronExpression. The fluent Cron helpers build common schedules without writing a cron string:
| Helper | Fires |
|---|---|
Cron.EveryMinute() | At the start of every minute |
Cron.EveryMinutes(n) | Every n minutes |
Cron.Hourly(atMinute: 0) | Once an hour, at the given minute |
Cron.Daily(hour, minute: 0) | Once a day, at the given time |
Cron.Weekly(day, hour, minute: 0) | Once a week, on the given day |
Cron.Monthly(dayOfMonth, hour, minute: 0) | Once a month, on the given day |
For anything the helpers do not cover, parse a raw expression with CronExpression.Parse. BackWave accepts standard cron in 5 fields, or 6 fields when you want a leading seconds field. Any other field count is rejected.
var cron = CronExpression.Parse("0 30 9 * * MON-FRI"); // 9:30 every weekdayCron expressions evaluate in UTC by default. Pass an IANA time zone id, such as "America/New_York", to evaluate in that zone. Across daylight-saving boundaries the schedule follows fixed rules: a local time skipped by the spring-forward gap fires once at the first valid instant after the gap, and a local time made ambiguous by the fall-back overlap fires only on its first occurrence, not twice. If the time zone id cannot be resolved on the host, UpsertRecurringAsync throws an ArgumentException at the call rather than failing silently later. The Cron API reference covers the full grammar and every helper.
Missed and overlapping runs#
Two policies control what happens when ticks pile up or run long.
| Policy | Argument | Default | Behavior |
|---|---|---|---|
| Catch-Up | catchUp | CatchUpPolicy.Skip | Skip mints nothing for ticks missed while the system was down. Coalesce mints exactly one make-up run standing in for the whole missed set. |
| No-Overlap | noOverlap | false | When true, a tick is skipped while a previously minted instance is still running, and the skipped tick is recorded visibly. This behaves like a per-schedule limit of one in-flight instance. |
Coalesce never replays every missed occurrence. A schedule that was down through a hundred ticks mints a single make-up job, not a hundred. The Schedule Recurring Jobs guide walks through these choices on a worked example.
A schedule whose time zone is missing on a particular host, or whose stored expression no longer parses, is surfaced as errored and skipped during minting. It never takes the Worker Group down.
How scheduled work becomes runnable#
Scheduled and recurring work becomes runnable through the same mechanism that runs everything else: Workers poll storage in Due-Time order. A deferred job is invisible to claims until its Due Time has passed, and the next poll after that instant claims it. Recurring schedules mint their due ticks as new jobs on a background maintenance sweep, and those jobs then flow through normal Due-Time claiming. A given cron tick mints exactly one job even when many nodes are running.
Polling is the sole source of truth. Wake-Up Hints are an optimization layered on top: when storage can signal that work just arrived, a Worker polls immediately instead of waiting for its timer. The system behaves identically, minus latency, if every hint is dropped, duplicated, or delayed. A job comes due whether or not a hint fires, and the next poll claims it. Where a database offers a notification channel it is used as a hint, and where it does not, such as SQL Server, the schedule is polling-only and fully correct.
The practical effect is a latency story, never a correctness one. With hints, the wait from enqueue to start is sub-second. Without them, a due one-off is claimed within the claim poll interval, which defaults to one second, and a cron tick is minted within the maintenance interval, which defaults to five seconds. Both intervals are configurable. The Limits and Defaults reference holds the full tuning table, and the Execution Model page describes the polling claim loop itself.
Where to go next#
- Schedule Recurring Jobs: the full how-to for
UpsertRecurringAsync, Catch-Up, and No-Overlap on a worked example. - Cron API: every
Cronhelper and the complete cron grammar. - Wake-Up Hints: how each storage adapter signals new work and the latency that follows.
- Queues: Due-Time ordering and the claim semantics that make a deferred job eligible.
- Execution Model: the polling claim loop that discovers due work.