Skip to main content

Recurring Jobs

Recurring jobs run on a cron schedule — database cleanup every night, report generation every Monday morning, health checks every minute. They implement IRecurringJob instead of IJob<T> and carry no payload.

Defining a recurring job

Create a class that implements IRecurringJob and decorate it with [JobConfig] to set the cron schedule:

[JobConfig(CronSchedule = "0 3 * * *", Queue = "maintenance", TimeoutSeconds = 300)]
public class CleanupExpiredSessions : IRecurringJob
{
private readonly AppDbContext _db;

public CleanupExpiredSessions(AppDbContext db) => _db = db;

public async Task ExecuteAsync(JobContext ctx)
{
var deleted = await _db.Sessions
.Where(s => s.ExpiresAt < DateTime.UtcNow)
.ExecuteDeleteAsync(ctx.CancellationToken);

ctx.Logger.LogInformation("Cleaned up {Count} expired sessions", deleted);
}
}

The CronSchedule property is only valid on IRecurringJob implementations. Everything else — Queue, MaxAttempts, TimeoutSeconds — works the same as regular jobs.

Cron expression format

Zeridion Flare accepts 5-field (minute-resolution) or 6-field (second-resolution) cron expressions. The server inspects the field count and parses accordingly: 5 fields use the standard minute hour dayOfMonth month dayOfWeek shape, 6 fields prepend second for sub-minute schedules.

5-field (minimum granularity: 1 minute)

┌───────────── minute (0–59)
│ ┌───────────── hour (0–23)
│ │ ┌───────────── day of month (1–31)
│ │ │ ┌───────────── month (1–12)
│ │ │ │ ┌───────────── day of week (0–6, Sunday = 0)
│ │ │ │ │
* * * * *
6-field (minimum granularity: 1 second)

┌───────────── second (0–59)
│ ┌───────────── minute (0–59)
│ │ ┌───────────── hour (0–23)
│ │ │ ┌───────────── day of month (1–31)
│ │ │ │ ┌───────────── month (1–12)
│ │ │ │ │ ┌───────────── day of week (0–6, Sunday = 0)
│ │ │ │ │ │
* * * * * *

Common schedules

ExpressionSchedule
* * * * *Every minute
*/5 * * * *Every 5 minutes
0 * * * *Every hour, on the hour
0 0 * * *Daily at midnight
0 3 * * *Daily at 3:00 AM
0 9 * * 1-5Weekdays at 9:00 AM
0 0 * * 1Every Monday at midnight
0 0 1 * *First of each month at midnight
0 0 1 1 *January 1st at midnight
*/30 * * * * *Every 30 seconds (6-field)

Supported syntax

  • Wildcards: *
  • Ranges: 1-5
  • Lists: 1,3,5
  • Steps: */15
  • Combined: 0 9-17 * * 1-5 (every hour, 9 AM to 5 PM, weekdays)
note

The evaluator ticks every 60 seconds, so even with a 6-field schedule the effective minimum trigger interval is one minute. A schedule like */10 * * * * * (every 10 seconds) will only fire once per evaluator tick — it doesn't enqueue six runs at once. Use 6-field schedules to anchor a recurring job to a specific second within the minute, not to fire faster than once a minute.

Timezone handling

By default, cron expressions are evaluated in UTC. To schedule in a local timezone, set the timezone field when managing recurring jobs via the API:

PUT /flare/v1/recurring/daily-report
Content-Type: application/json

{
"job_type": "GenerateDailyReport",
"cron_expression": "0 9 * * 1-5",
"timezone": "America/New_York",
"queue": "reports"
}

Timezone identifiers use the IANA Time Zone Database format (e.g., America/New_York, Europe/London, Asia/Tokyo). The API validates the timezone and returns 400 invalid_timezone if it is not recognized.

Daylight-saving transitions are handled by the host's system timezone library: non-existent local hours (the "spring forward" gap) advance to the next valid wall-clock time, and ambiguous local hours (the "fall back" repeat) pick the earlier of the two occurrences. This matches the default policy of the underlying cron evaluator but is not configurable, so always test schedules whose firing time falls within a DST boundary in your target timezone — particularly daily jobs scheduled between 2:00–3:00 AM in zones that observe DST.

Auto-registration from the SDK

When the SDK worker starts, it automatically discovers all IRecurringJob types with a CronSchedule attribute and registers them with Flare:

Each recurring job is registered with the ID pattern rjob_{jobType}. If the recurring schedule already exists, it is updated (upserted) with the latest configuration from the attribute.

This means you define recurring jobs in code and they are registered automatically — no manual API calls needed.

How scheduling works

Flare periodically evaluates enabled recurring schedules and enqueues a run for each schedule whose next-run time has arrived:

Each enqueued run is an ordinary job that goes through the standard processing pipeline — claim, execute, acknowledge, retry. The recurring schedule and the individual runs are independent.

Managing recurring jobs via API

Upsert a schedule

PUT /flare/v1/recurring/{id}

Creates or updates a recurring job schedule. The id is user-defined (e.g., daily-cleanup, hourly-sync):

{
"job_type": "CleanupExpiredSessions",
"cron_expression": "0 3 * * *",
"timezone": "UTC",
"queue": "maintenance",
"max_attempts": 3,
"timeout_seconds": 300,
"enabled": true
}

List all schedules

GET /flare/v1/recurring

Returns all recurring job schedules for the authenticated project.

Delete a schedule

DELETE /flare/v1/recurring/{id}

Removes a recurring job schedule. Already-enqueued runs are not affected.

Enable/disable

Set "enabled": false in the upsert request to pause a recurring schedule without deleting it. Flare skips disabled schedules during evaluation.

Missed run catch-up

If Flare's schedule evaluator is offline or delayed (e.g., during a deployment), it may miss one or more scheduled runs. When it resumes:

  • The evaluator enqueues up to one run per tick, per schedule — each tick checks NextRunAt <= now and fires one job for every schedule whose deadline has passed. If a schedule's evaluator missed several consecutive ticks (e.g. a 10-minute deployment window for a */5 * * * * schedule), it does not backfill all skipped runs in a single tick, but it will fire one run on the recovery tick, another on the next 60-second tick, and so on — so a long offline period can yield several enqueued jobs across the recovery window, one per tick until NextRunAt is back in the future.
  • After each enqueue, NextRunAt is recomputed from the current time using the cron expression and timezone — there is no replay of historical occurrences.
warning

Design every recurring job to be idempotent across restart recovery. During a deployment, leader failover, or extended outage, the evaluator may fire the same logical run more than once (different job_id, same underlying schedule). Use the same patterns as one-shot job idempotency — database upserts, check-before-write, or downstream idempotency keys derived from a deterministic timestamp like daily-report:{ctx.JobId[..]} or cleanup:{utcDate:yyyy-MM-dd}.

tip

For critical schedules, monitor the LastRunAt field in GET /flare/v1/recurring to verify that jobs are running on time, and emit a metric whenever your IRecurringJob.ExecuteAsync runs so you can alert on missing executions independently of the server-side counter.

Error handling

Recurring job runs follow the same retry strategy as regular jobs:

  • If ExecuteAsync throws, the run is retried with exponential backoff up to MaxAttempts
  • If all attempts are exhausted, the run moves to DeadLetter
  • The recurring schedule is not affected — the next cron occurrence still fires on time

A failing run does not block future runs. Each scheduled execution is an independent job.

Unique job type constraint

Each project can only have one recurring schedule per job_type. If you try to create a second schedule with the same job_type under a different ID, the API returns 409 recurring_job_type_conflict.

Best practices

  1. Keep recurring jobs lightweight — they run at regular intervals, so expensive operations accumulate. Offload heavy work to separate fire-and-forget jobs if needed.

  2. Use dedicated queues — isolate recurring workloads with [JobConfig(Queue = "maintenance")] so they don't compete with user-triggered jobs for worker slots.

  3. Design for idempotency — cron timing is approximate (schedules are evaluated periodically). Your job may run a few seconds early or late. If exact timing matters, check timestamps inside ExecuteAsync.

  4. Monitor recurring-job-specific signals, not just generic success rates. Generic GET /flare/v1/metrics/summary shows aggregate success rate across all jobs, which can mask a single broken cron schedule. For recurring jobs specifically, alert on:

    • LastRunAt lag — for each row in GET /flare/v1/recurring, compute now - LastRunAt. Alert when the lag exceeds 2× the schedule's expected interval (e.g. a 0 * * * * hourly job that hasn't run for 2 hours).
    • Run-count anomalies — track how many runs each recurring_job_id produced in the last 24 hours via GET /flare/v1/jobs?state=succeeded&job_type=<type> and compare against the expected count from the cron schedule (e.g. a */15 * * * * schedule should produce ~96 runs/day).
    • Dead-letter rate per recurring type — filter GET /flare/v1/jobs?state=dead_letter&job_type=<type> and alert when a single recurring job_type accumulates more than N dead letters in 24 hours. Recurring failures often signal a broken external dependency (DB, third-party API) that the cron schedule keeps re-hitting.
    • Evaluator liveness — if every recurring schedule's LastRunAt lag grows simultaneously, the evaluator itself is down. Use the CronScheduler's health endpoint and log channel to distinguish "one schedule broken" from "scheduler offline".

See also