Scheduled and Periodic Work

PeriodicTimer gives a recurring job a clean loop, but two replicas run it twice, so you need a lease or an outside scheduler.

Syntaxwhile (await timer.WaitForNextTickAsync(stoppingToken)) { /* one pass */ }

Some work happens on a clock: a report at 02:15, a cleanup every five minutes. You already have a loop that runs for the life of the process. What you need is a dependable wait between passes.

PeriodicTimer

PeriodicTimer, added in .NET 6, is built for exactly this. You create it with a period and then call WaitForNextTickAsync. It returns true when a tick fires and false once the timer is disposed, which makes a while loop read cleanly. If you pass it a token that is later cancelled, the wait throws OperationCanceledException, so catch that or dispose the timer to end the loop.

using PeriodicTimer timer = new(period); is a using declaration. It is shorthand for wrapping the rest of the block in using (PeriodicTimer timer = new(period)) { ... }. Both dispose the timer when the block ends, and they are the same thing.

Two replicas run it twice

This is the part people miss. A timer belongs to a process. Run three copies of your service for availability and the nightly report runs three times, because each copy has its own timer. Nothing in .NET prevents that.

There are two answers. Leader election means each replica first tries to take a named lease, a claim on the job held in shared storage, usually one conditional UPDATE on a database row. Only one replica wins. The lease carries an expiry so a replica that dies while holding it does not block the job forever.

The other answer is to move the schedule out of the application. A Kubernetes CronJob starts a one-shot process at the right time and nothing runs between times. Set concurrencyPolicy: Forbid so a slow run does not overlap the next one.

Example

Example · csharp
// PeriodicTimer was added in .NET 6. The period is the gap between ticks, so
// the time your own work takes does not make the schedule drift.
using PeriodicTimer timer = new(TimeSpan.FromMilliseconds(10));
using CancellationTokenSource stopping = new();

int runs = 0;

// true means a tick fired. false means the timer was disposed. Cancelling the
// token instead throws OperationCanceledException out of the wait.
while (await timer.WaitForNextTickAsync(stopping.Token))
{
    runs++;
    Console.WriteLine($"run {runs}");

    if (runs == 3)
    {
        break;
    }
}

Console.WriteLine($"runs completed: {runs}");

// Output:
// run 1
// run 2
// run 3
// runs completed: 3

When to use it

  • Delete abandoned shopping carts every five minutes without a separate service to deploy.
  • Email a summary report at 02:15 once, even though the API runs on four replicas.
  • Refresh a cached exchange-rate table every minute inside the process that reads it.
  • Re-check a slow external provider every thirty seconds and update a health flag.

More examples

A periodic pass that needs a scoped service

The scope-per-pass pattern is the one to memorise: a hosted service lives for the whole process, so it must not hold a DbContext, and IServiceScopeFactory lets it create one for the duration of a single pass. The try/catch turns shutdown into a clean exit instead of a logged error.

Example · csharp
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace Shop;

public sealed class ExpireCarts(
    IServiceScopeFactory scopeFactory,
    ILogger<ExpireCarts> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using PeriodicTimer timer = new(TimeSpan.FromMinutes(5));

        try
        {
            while (await timer.WaitForNextTickAsync(stoppingToken))
            {
                // The service itself is a singleton; a DbContext is scoped.
                // Open a scope per pass so each pass gets a fresh DbContext.
                using IServiceScope scope = scopeFactory.CreateScope();
                ShopContext db = scope.ServiceProvider.GetRequiredService<ShopContext>();

                int removed = await db.Carts
                    .Where(c => c.AbandonedDays > 30)
                    .ExecuteDeleteAsync(stoppingToken);

                logger.LogInformation("Removed {Count} abandoned carts", removed);
            }
        }
        catch (OperationCanceledException)
        {
            // Shutdown cancelled the wait. Nothing to clean up.
        }
    }
}

Guarding the job with a lease so only one replica runs it

Use this when the job must run exactly once across every replica and you want to keep it inside the service. The expiry matters: without it, a replica that is killed mid-run would hold the lease forever. Note that the timer is given CancellationToken.None and shutdown is checked separately, which avoids the exception the cancelled wait would throw.

Example · csharp
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace Reports;

// Implemented over one conditional UPDATE on a leases table:
//   UPDATE leases SET owner = @owner, expires_at = @expires
//   WHERE name = @name AND (owner = @owner OR expires_at < @now);
// One row affected means you won. Zero means someone else holds it.
public interface ILeaseStore
{
    Task<bool> TryAcquireAsync(
        string jobName,
        string owner,
        TimeSpan duration,
        CancellationToken cancellationToken);
}

public sealed class NightlyReport(
    ILeaseStore leases,
    ILogger<NightlyReport> logger) : BackgroundService
{
    private readonly string _owner = "replica-a";   // the pod name in production

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using PeriodicTimer timer = new(TimeSpan.FromMinutes(1));

        while (await timer.WaitForNextTickAsync(CancellationToken.None))
        {
            if (stoppingToken.IsCancellationRequested)
            {
                return;
            }

            bool won = await leases.TryAcquireAsync(
                "nightly-report", _owner, TimeSpan.FromMinutes(10), stoppingToken);

            if (!won)
            {
                logger.LogInformation("Another replica holds the lease, skipping");
                continue;
            }

            logger.LogInformation("Running the nightly report");
        }
    }
}

Letting Kubernetes own the schedule

The program behind this is a plain console application that does one pass and exits, returning 0 on success and non-zero on failure, so the Job can tell whether to retry. Prefer this over an in-process timer when the work is occasional and heavy: nothing is resident between runs, and concurrencyPolicy gives you the overlap protection a lease would otherwise provide.

Example · yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-report
spec:
  schedule: "15 2 * * *"        # 02:15 every day, in the cluster's time zone
  concurrencyPolicy: Forbid     # never overlap with a run still going
  successfulJobsHistoryLimit: 3
  jobTemplate:
    spec:
      backoffLimit: 2           # retry a failed run twice, then give up
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: report
              image: registry.example.com/shop/report:1.4.0
              args: ["--job", "nightly-report"]

Discussion

  • Be the first to comment on this lesson.