Skip to content

Recurring jobs

Define and manage recurring schedules with cron expressions, time zones and overlap policies.

2 min read

Recurring jobs are payloadless. A code-defined schedule lives on [Job]:

[Handler, Job(Cron = "0 */5 * * * *", TimeZone = "Europe/Vienna")]
public sealed partial class CleanupSessionsJob(AppDbContext db)
{
	private ValueTask HandleAsync(EmptyJobRequest request, CancellationToken cancellationToken) =>
		new(db.DeleteExpiredSessions(cancellationToken));
}

Cron expressions accept five fields (minute precision) or six fields (seconds first). Time zones are IANA identifiers and default to UTC. Cron and time-zone values are validated during startup; invalid cron is also an analyzer error.

Inject CleanupSessionsJob.Scheduler to trigger a code-defined schedule immediately:

public sealed class CleanupOperations(CleanupSessionsJob.Scheduler scheduler)
{
	public async ValueTask RunNowAsync(CancellationToken cancellationToken)
	{
		_ = await scheduler.TriggerNowAsync(cancellationToken);
	}
}

At startup the hosted service upserts every code-defined schedule and removes obsolete code-defined rows. Dynamic rows are left alone. This reconciliation means a deploy can change a cron expression, but two versions of an application should not intentionally define different schedules under the same name.

When the persisted cron expression and time zone are unchanged, reconciliation preserves its stored NextRunAt, including an occurrence that became due while the application was stopped. A changed cron expression or time zone recomputes the next occurrence from the current time.

Dynamic schedules

Omit Cron from a payloadless job and inject its generated scheduler as IRecurringJobScheduler:

[Handler, Job(Name = "tenant-cleanup")]
public sealed partial class TenantCleanupJob(AppDbContext db)
{
	private ValueTask HandleAsync(EmptyJobRequest request, CancellationToken cancellationToken) =>
		new(db.DeleteExpiredSessions(cancellationToken));
}

public sealed class TenantScheduleManager(TenantCleanupJob.Scheduler tenantCleanupScheduler)
{
	public async ValueTask ConfigureAsync(CancellationToken cancellationToken)
	{
		await tenantCleanupScheduler.AddOrUpdateRecurringAsync(
			"tenant-42-cleanup",
			"0 0 3 * * *",
			"UTC",
			cancellationToken
		);
	}

	public ValueTask RemoveAsync(CancellationToken cancellationToken) =>
		tenantCleanupScheduler.RemoveRecurringAsync("tenant-42-cleanup", cancellationToken);
}

AddOrUpdateRecurringAsync is durable and idempotently replaces the named dynamic schedule. TriggerNowAsync creates an immediate invocation without moving the next cron occurrence. The dashboard can trigger, pause and resume existing schedules.

Overlap policy

PolicyWhen the previous occurrence is still active
SkipDo not materialize this occurrence.
QueueMaterialize it and let it wait.
ConcurrentAllow both invocations to execute, subject to other concurrency limits.

Materialization is coordinated in durable storage, so Recurring capability is required. Redis and the SQL providers support it; graph support is unrelated.

NodaTime

Install Immediate.Jobs.NodaTime to configure NodaTime payload serialization and use Duration, Instant and DateTimeZone scheduling overloads. See the dedicated NodaTime guide for registration, examples and the complete package surface.