Scheduled Jobs Across Replicas

Choose between Kubernetes CronJobs, in-app schedulers with distributed locks and Hangfire or Quartz.NET, and run each job once per schedule.

Syntaxawait using var tx = await conn.BeginTransactionAsync(ct); var leader = (bool)(await new NpgsqlCommand("SELECT pg_try_advisory_xact_lock(72001)", conn, tx).ExecuteScalarAsync(ct))!; if (!leader) return 0;

A BackgroundService with a timer is fine for one instance. Scale the API to four replicas and the nightly invoice run sends four emails per customer. Scheduled work across replicas needs one of three designs.

The options

DesignStrengthsCosts
Kubernetes CronJob running a job programOne Job per schedule tick (approximately once; set concurrencyPolicy: Forbid to prevent overlaps), isolated resources, exit code and history visible in kubectl, no scheduler codePod start-up per run, minute granularity, needs the job packaged as a container command
In-app timer plus a distributed lockNo extra infrastructure, sub-minute intervalsLock semantics to get right, job competes with requests for CPU and memory
Job library with persistent storage (Hangfire, Quartz.NET)Dashboards, retries, misfire handling, ad-hoc and delayed jobsA dependency with its own tables, upgrades and licensing

Kubernetes CronJob

The schedule is cron syntax, evaluated in the controller's time zone (UTC on managed clusters) unless you set timeZone. Set concurrencyPolicy: Forbid so a slow run is not overlapped by the next. startingDeadlineSeconds bounds how late a missed run may still start; without it, a controller that missed more than 100 schedules stops scheduling the CronJob. backoffLimit (default 6) retries failed pods and activeDeadlineSeconds kills a hung run. Kubernetes documents CronJob delivery as approximately once: rarely a run is skipped or duplicated, so the job must still be idempotent. The job program must exit non-zero on failure, or Kubernetes records a success.

Distributed locks

  • PostgreSQL advisory locks. pg_try_advisory_xact_lock(key) inside a transaction is released at commit or rollback, which suits a run that does its work in that transaction. pg_try_advisory_lock(key) is held by the session: it must be unlocked on the same connection. Returning that connection to the pool leaves the lock held by an idle connection nobody owns until Npgsql's reset (DISCARD ALL) runs on reuse or the connection is pruned, so it is neither reliably held nor reliably released. Keep a dedicated, non-pooled connection for the lock's lifetime. Failover is bounded by how fast the server notices a dead client: after a node loss it waits for TCP keepalive (2 hours by default on Linux), so set tcp_keepalives_idle, tcp_keepalives_interval and tcp_keepalives_count, or tcp_user_timeout, to about 30 seconds in total.
  • Redis. SET lock:invoice-run <token> NX PX 60000; release with a script that deletes only if the token matches, so an expired holder cannot delete its successor's lock.
  • Leases (a database row with expires_at, or a Kubernetes Lease object) give a renewable leader.

Every lock that can expire can be held by two processes at once: the holder pauses for a long GC or a network stall, the lease expires, a second replica takes over, the first one resumes. Make the work idempotent, or pass a fencing token (an increasing number from the lock) that the database rejects when stale.

Hangfire and Quartz.NET

Hangfire stores jobs in SQL Server (built in), PostgreSQL (community package) or Redis (commercial Hangfire.Pro.Redis, or community packages), runs recurring jobs on cron expressions with automatic retries, and ships a dashboard. Its core is LGPL; extras such as batches are commercial. Quartz.NET (Apache 2.0) has rich triggers, calendars and misfire policies, and clusters through its ADO.NET job store, where nodes coordinate with database row locks. Choose a library when you need many dynamic, user-defined schedules or a dashboard for operators; choose a CronJob for a handful of fixed operational jobs.

Example

Example · csharp
using Npgsql;

// Invoice reminder job: run by a Kubernetes CronJob, or by every replica on a timer.
// The transaction-scoped advisory lock lets exactly one copy do the work per run.
var connectionString = Environment.GetEnvironmentVariable("ConnectionStrings__Billing")
    ?? throw new InvalidOperationException("ConnectionStrings__Billing is not set");

using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10));
var ct = cts.Token;
const long InvoiceReminderLockKey = 72001;

try
{
    await using var dataSource = NpgsqlDataSource.Create(connectionString);
    await using var conn = await dataSource.OpenConnectionAsync(ct);
    await using var tx = await conn.BeginTransactionAsync(ct);

    await using (var lockCmd = new NpgsqlCommand("SELECT pg_try_advisory_xact_lock(@key)", conn, tx))
    {
        lockCmd.Parameters.AddWithValue("key", InvoiceReminderLockKey);
        if (!(bool)(await lockCmd.ExecuteScalarAsync(ct))!)
        {
            Console.WriteLine("Another instance holds the invoice-reminder lock; nothing to do.");
            return 0;
        }
    }

    // Idempotent: only invoices not reminded in the last 7 days are picked, and the mark is in the same transaction.
    await using var mark = new NpgsqlCommand("""
        UPDATE invoices
        SET last_reminder_at = now()
        WHERE status = 'Unpaid'
          AND due_date < current_date
          AND (last_reminder_at IS NULL OR last_reminder_at < now() - interval '7 days')
        RETURNING invoice_number, customer_email
        """, conn, tx);

    var reminders = new List<(string Number, string Email)>();
    await using (var reader = await mark.ExecuteReaderAsync(ct))
        while (await reader.ReadAsync(ct))
            reminders.Add((reader.GetString(0), reader.GetString(1)));

    // Queue the emails in the same transaction (the outbox), never send them before commit.
    foreach (var (number, email) in reminders)
    {
        await using var outbox = new NpgsqlCommand(
            "INSERT INTO outbox_messages (id, type, payload, occurred_at) VALUES (gen_random_uuid(), 'InvoiceReminder', jsonb_build_object('invoice', @n, 'email', @e), now())",
            conn, tx);
        outbox.Parameters.AddWithValue("n", number);
        outbox.Parameters.AddWithValue("e", email);
        await outbox.ExecuteNonQueryAsync(ct);
    }

    await tx.CommitAsync(ct); // releases the advisory lock
    Console.WriteLine($"Queued {reminders.Count} invoice reminders.");
    return 0;
}
catch (Exception ex)
{
    Console.Error.WriteLine($"Invoice reminder run failed: {ex.Message}");
    return 1; // non-zero so the CronJob records a failure and retries per backoffLimit
}

When to use it

  • Send overdue-invoice reminders once a night at 02:30 London time from a CronJob, even with the API running on six replicas.
  • Let exactly one of four API pods settle card payments every minute, with a follower taking over within 30 seconds of a clean leader exit, or after server TCP keepalive if a node is lost.
  • Re-run last night's failed export by hand with kubectl create job --from=cronjob, using the same image and secrets.

More examples

A CronJob that runs the job once per night

<code>Forbid</code> skips a run while the previous one is still going, <code>startingDeadlineSeconds</code> lets a run missed during a control-plane hiccup start up to 10 minutes late, and <code>activeDeadlineSeconds</code> kills a hung run after 15 minutes. With <code>restartPolicy: Never</code>, each of the two retries is a fresh pod with its own logs.

Example · yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: invoice-reminders
  namespace: billing
spec:
  schedule: "30 2 * * *"
  timeZone: "Europe/London"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 600
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5
  jobTemplate:
    spec:
      backoffLimit: 2
      activeDeadlineSeconds: 900
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: job
              image: registry.example.com/billing/jobs:1.14.0
              args: ["invoice-reminders"]
              env:
                - name: ConnectionStrings__Billing
                  valueFrom:
                    secretKeyRef:
                      name: billing-db
                      key: connection-string
              resources:
                requests: { cpu: "100m", memory: "128Mi" }
                limits: { memory: "256Mi" }

A leader-only worker holding a session advisory lock

A session-level lock belongs to the connection, so this worker keeps one non-pooled connection open while it leads and checks it each pass. When the leader pod exits cleanly, its socket closes, PostgreSQL releases the lock and a follower takes over within 30 seconds. After a node loss or partition, takeover waits until the server's TCP keepalive declares the client dead, so set <code>tcp_keepalives_idle</code>, <code>tcp_keepalives_interval</code> and <code>tcp_keepalives_count</code> (or <code>tcp_user_timeout</code>) to about 30 seconds.

Example · csharp
using Npgsql;

public sealed class SettlementLeader(IConfiguration config, ILogger<SettlementLeader> logger) : BackgroundService
{
    private const long LockKey = 72002;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // Pooling off: closing this connection must really end the session, and with it the lock.
        // A pooled connection would go back to the pool with the lock still held.
        var lockConnectionString = new NpgsqlConnectionStringBuilder(config.GetConnectionString("Billing"))
        {
            Pooling = false,
            KeepAlive = 15, // client side; the server needs its own tcp_keepalives_* to spot a dead leader
        }.ConnectionString;

        while (!stoppingToken.IsCancellationRequested)
        {
            // A dedicated connection: the session lock lives exactly as long as this connection.
            await using var conn = new NpgsqlConnection(lockConnectionString);
            await conn.OpenAsync(stoppingToken);
            await using var tryLock = new NpgsqlCommand("SELECT pg_try_advisory_lock(@key)", conn);
            tryLock.Parameters.AddWithValue("key", LockKey);

            if (!(bool)(await tryLock.ExecuteScalarAsync(stoppingToken))!)
            {
                await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); // follower: try again later
                continue;
            }

            logger.LogInformation("This replica is the settlement leader");
            try
            {
                using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
                while (await timer.WaitForNextTickAsync(stoppingToken))
                {
                    await using var ping = new NpgsqlCommand("SELECT 1", conn); // a dead connection means the lock is gone
                    await ping.ExecuteScalarAsync(stoppingToken);
                    logger.LogInformation("Settling card payments captured in the last minute");
                }
            }
            catch (NpgsqlException ex)
            {
                logger.LogWarning(ex, "Lost the leader connection; re-electing");
            }
            // Disposing this non-pooled connection ends the session and releases the lock for the next leader.
        }
    }
}

Trigger and inspect runs by hand

Operational control is a large part of why CronJobs suit fixed jobs: a manual run uses the same pod template and secrets as the scheduled one, <code>suspend</code> stops new runs during an incident, and the status fields show when the job last succeeded, which is what an alert should watch.

Example · bash
# Run tonight's job now, for example after fixing a bug
kubectl create job --from=cronjob/invoice-reminders invoice-reminders-manual-1 -n billing

# Watch it and read its output
kubectl get jobs -n billing --watch
kubectl logs job/invoice-reminders-manual-1 -n billing

# Pause the schedule during an incident without deleting it
kubectl patch cronjob invoice-reminders -n billing -p '{"spec":{"suspend":true}}'

# See the last scheduled and successful times
kubectl get cronjob invoice-reminders -n billing -o jsonpath='{.status.lastScheduleTime}{" "}{.status.lastSuccessfulTime}{"\n"}'

Discussion

  • Be the first to comment on this lesson.