Schedules

Create jobs on a cron timetable or at one fixed time, handle missed runs and daylight saving, and pause or trigger a schedule.

On this page

A schedule turns "every night at 02:00" into ordinary jobs. The scheduler only creates jobs; workers run them like any other job, with the same retries, limits and guarantees.

Text
builder.Services.AddRunwick(runwick =>
{
    runwick.Application("my-app")
        .UsePostgres(connectionString)
        .AddJobsFromAssemblyContaining<NightlyCleanup>();

    runwick.AddScheduler();                                       // run the scheduler role in this process

    runwick.AddSchedule("nightly-cleanup", new NightlyCleanup(RetentionDays: 30))
        .Cron("0 2 * * *", timeZone: "Europe/Berlin");
});

Turn on the scheduler capability first

Schedules need the store capability scheduler, raised once per application. Until then, startup fails with scheduler_not_active, and the error message names the command to run.

  1. Start the application once

    This registers the application in the store. The first start fails at the scheduler; that is expected.

  2. Activate the capability

    Text
    runwick store activate-capability --name scheduler --application my-app --provider postgres --connection-env MY_CONN

    Use --provider sqlite or --provider redis for the other stores. From code: RunwickPostgresMigrations.ActivateCapabilityAsync(connectionString, "my-app", "scheduler", "deploy"), or the same method on RunwickSqliteMigrations and RunwickRedisMigrations.

  3. Restart

    This is done once per application and cannot be undone. Older Runwick binaries that do not know the capability can no longer create jobs for that application afterwards.

Where the scheduler runs

AddScheduler() runs the scheduler role in this process. You can run it in as many processes as you like. There is no leader election: each occurrence has a unique key in the store, so only one process can create its job.

AddSchedule without AddScheduler in the same process is allowed. The schedule is then registered the first time a process that runs the scheduler and declares it starts.

Text
runwick.AddScheduler(s =>
{
    s.PollInterval = TimeSpan.FromSeconds(1);       // while work is due
    s.IdlePollInterval = TimeSpan.FromSeconds(5);   // backs off to this when idle
    s.JobsPerSecond = 100;                          // per scheduler process
    s.Burst = 32;
});

The values above are the defaults. AddScheduler may be called once.

Defining a schedule

AddSchedule(key, job) takes a key and the job instance to create on each occurrence. The payload is frozen when the schedule is registered. The job type must be registered. Each key may be declared once.

Every schedule has exactly one of Cron or Once (schedule_definition_missing otherwise).

Cron

Text
runwick.AddSchedule("hourly-sync", new SyncCatalog())
    .Cron("15 * * * *");                                         // 15 past every hour, UTC

runwick.AddSchedule("weekday-report", new BuildReport("daily", 0))
    .Cron("0 9 * * MON-FRI", timeZone: "America/New_York");

runwick.AddSchedule("heartbeat", new Heartbeat())
    .Cron("*/30 * * * * *", ScheduleCronFormat.SixField);        // every 30 seconds
FormatFields
FiveField (default)minute, hour, day of month, month, day of week
SixFieldsecond, minute, hour, day of month, month, day of week

Each field takes *, a value, a range a-b, a step */n or a-b/n, or a comma list of those. Months accept JAN–DEC and days of week SUN–SAT; Sunday is 0 or 7.

Not supported: macros such as @daily, ?, L, W, #, H, year fields and TZ= prefixes. An expression is at most 256 characters.

When both day of month and day of week are restricted, a date matches if either matches. 0 9 1 * MON runs at 09:00 on the 1st of every month and on every Monday.

Time zones

Without a zone, schedules run in UTC. A zone must be an IANA ID such as Europe/Berlin. Windows IDs and Local are refused. Runwick ships a pinned copy of the time-zone database, so a server's OS update cannot silently move a schedule.

Daylight saving needs two decisions:

Text
runwick.AddSchedule("berlin-0230", new Tick())
    .Cron("30 2 * * *", timeZone: "Europe/Berlin")
    .OnGap(ScheduleGap.Shift)       // 02:30 does not exist in spring: run at the next valid time
    .OnFold(ScheduleFold.Earlier);  // 02:30 happens twice in autumn: run the first one
OptionValues
OnGapSkip (default), Shift
OnFoldEarlier (default), Later, Both

Once

Text
runwick.AddSchedule("launch-mail", new SendLaunchMail())
    .Once(new DateTimeOffset(2026, 12, 1, 9, 0, 0, TimeSpan.Zero));

After its one occurrence the schedule is finished. For a single delayed job you usually want DispatchOptions.NotBefore instead; see Dispatching.

At most one job per occurrence

Each occurrence, such as "02:00 on 5 March", creates at most one job. The job it creates is at-least-once like every job, so its handler can still run more than once. Make it safe to repeat.

When a schedule is first registered, the first occurrence is the first one after that moment. There is no backfill of earlier times.

The handler can read which occurrence it belongs to from IJobContext.Schedule. See Job context.

Missed occurrences

If no scheduler was running when an occurrence was due, and it is now more than the grace period late (default 60 seconds), the misfire policy decides:

Text
runwick.AddSchedule("nightly-cleanup", new NightlyCleanup(30))
    .Cron("0 2 * * *")
    .OnMisfire(MisfirePolicy.CatchUp, grace: TimeSpan.FromMinutes(5))
    .CatchUpLimits(maxJobsPerEpisode: 100, maxAge: TimeSpan.FromDays(2));
PolicyWhat happens
RunOnce (default)One job now, recorded as covering the whole missed range.
SkipNo job. The skipped range is recorded.
CatchUpOne job per missed occurrence, oldest first, in small paced batches.

Grace can be 0 to 24 hours. Catch-up stops at 1,000 jobs per episode by default (1 to 10,000) and refuses occurrences older than 7 days by default (1 minute to 31 days). When it hits either limit it creates no more jobs and flags the schedule as needing review, rather than silently dropping occurrences.

Overlap

By default a new occurrence's job can run while the previous one is still running.

Text
runwick.AddSchedule("import", new ImportFeed()).Cron("*/5 * * * *").Overlap(ScheduleOverlap.Serialize);

Serialize adds a capacity-1 concurrency scope shared by the schedule's jobs, so only one runs at a time. It does not stop jobs being created: if each run takes longer than the interval, jobs queue up.

Options for the created jobs

WithOptions sets the queue, priority, metadata and active uniqueness of every job the schedule creates:

Text
runwick.AddSchedule("import", new ImportFeed())
    .Cron("*/5 * * * *")
    .WithOptions(new DispatchOptions { Queue = "imports", Priority = "low" });
  • NotBefore is refused (schedule_delay_unsupported).
  • Window uniqueness is refused (schedule_window_unsupported).
  • An active key makes every occurrence share one key, so it requires .AllowOccurrenceReuse() (schedule_occurrence_reuse_required). An occurrence that finds the key held is linked to the existing job instead of creating a new one.

Changing a schedule in code

Registration never overwrites. If you change a schedule's cron, zone, payload or options in code and redeploy, the scheduler logs a warning that the stored definition differs, and the stored definition stays in effect. Pause and enabled state are never reset by a restart either.

There is no public operation to edit a stored schedule in this release. To change timing, declare the new definition under a new key and pause the old one.

Pausing, resuming and triggering

Pausing a schedule stops it creating jobs. Jobs it already created are not paused or cancelled.

Text
runwick schedules list
runwick schedules pause nightly-cleanup
runwick schedules resume nightly-cleanup --policy skipPaused
runwick schedules trigger nightly-cleanup

On resume you choose what happens to occurrences missed while paused: SkipPaused, RunOnce or CatchUp.

Trigger creates one extra job now, even while the schedule is paused. It never moves the timetable. The trigger ID is an idempotency key: the same ID returns the same job.

A schedule flagged as needing review can be cleared with runwick schedules revalidate <key> once you have fixed the cause. Every one of these actions is audited. IScheduleControl trusts the actor you pass; the HTTP API, dashboard and CLI check permissions.

Next