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 uses 5-field cron expressions, parsed by the Cronos library:

┌───────────── 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

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)
warning

6-field expressions (with seconds) are not supported. The minimum granularity is one 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 /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.

Timezone-aware scheduling correctly handles daylight saving time transitions.

Auto-registration from the SDK

When the SDK worker starts, it automatically discovers all IRecurringJob types with a CronSchedule attribute and registers them with the API via POST /v1/workers/register:

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

The CronEvaluatorService (running in the Zeridion.CronScheduler host project) evaluates due recurring jobs every 60 seconds:

Each enqueued run is a normal Job entity that goes through the standard processing pipeline — poll, execute, ack, retry. The recurring schedule and the individual runs are independent.

Managing recurring jobs via API

Upsert a schedule

PUT /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 /v1/recurring

Returns all recurring job schedules for the authenticated project.

Delete a schedule

DELETE /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. The CronEvaluatorService skips disabled schedules during evaluation.

Missed run catch-up

If the CronScheduler is offline or delayed (e.g., during a deployment), it may miss one or more scheduled runs. When it comes back online:

  • It enqueues one catch-up run per recurring job (not a backfill of all missed runs)
  • This prevents a thundering herd of duplicate jobs on restart
  • The NextRunAt is recalculated from the current time after the catch-up
tip

For critical schedules, monitor the LastRunAt field in GET /v1/recurring to verify that jobs are running on time.

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 (evaluated every 60 seconds). Your job may run a few seconds early or late. If exact timing matters, check timestamps inside ExecuteAsync.

  4. Monitor with metrics — use GET /v1/metrics/summary to track success rates for your recurring job types. A rising failure count signals a problem before it becomes critical.

See also