Skip to content

Cron Scheduling

zzz_jobs includes a built-in cron scheduler that evaluates standard 5-field cron expressions once per minute and enqueues matching jobs automatically. This is useful for periodic tasks like nightly cleanups, report generation, or data synchronization.

The cron scheduler runs as a dedicated thread inside the supervisor. Every ~30 seconds it checks the current minute against all registered cron entries. When an expression matches, the scheduler enqueues a new job for the associated worker. Each entry tracks last_run to avoid double-enqueuing within the same minute.

Register cron jobs on the supervisor after registering workers and before (or after) calling start():

const zzz_jobs = @import("zzz_jobs");
var supervisor = try zzz_jobs.MemorySupervisor.init(.{}, .{
.queues = &.{.{ .name = "default", .concurrency = 5 }},
});
defer supervisor.deinit();
// Register the worker that will handle cron-triggered jobs
supervisor.registerWorker(.{
.name = "cleanup_worker",
.handler = &cleanupHandler,
});
// Schedule it to run at 2:00 AM every day
try supervisor.registerCron(
"nightly_cleanup", // unique name for this cron entry
"0 2 * * *", // cron expression
"cleanup_worker", // worker name to enqueue
"{}", // job args
.{}, // JobOpts (queue, priority, etc.)
);
try supervisor.start();
ParameterTypeDescription
name[]const u8A unique identifier for this cron entry (max 128 chars)
cron_expr[]const u8Standard 5-field cron expression
worker[]const u8Name of the registered worker to dispatch to
args[]const u8Serialized arguments passed to the worker (max 512 chars)
optsJobOptsJob options: queue, priority, max_attempts, unique_key, etc.

The scheduler supports up to 32 cron entries.

zzz_jobs uses standard 5-field cron expressions. Each field is separated by whitespace:

┌────────── minute (0-59)
│ ┌──────── hour (0-23)
│ │ ┌────── day of month (1-31)
│ │ │ ┌──── month (1-12)
│ │ │ │ ┌── day of week (0-6, Sun=0)
│ │ │ │ │
* * * * *
SyntaxMeaningExample
*Every value in the range* * * * * — every minute
NSpecific value30 * * * * — at minute 30
N-MRange (inclusive)0 9-17 * * * — hours 9 through 17
*/NStep (every Nth value)*/15 * * * * — every 15 minutes
N-M/SRange with step0-30/10 * * * * — minutes 0, 10, 20, 30
A,B,CList of values0,15,30,45 * * * * — at 0, 15, 30, 45
ExpressionSchedule
* * * * *Every minute
*/5 * * * *Every 5 minutes
*/15 * * * *Every 15 minutes
0 * * * *Every hour (at minute 0)
0 0 * * *Midnight daily
0 2 * * *2:00 AM daily
0 9 * * 1-59:00 AM on weekdays (Mon-Fri)
0 0 1 * *Midnight on the 1st of each month
30 4 * * 04:30 AM every Sunday
0 0 1 1 *Midnight on January 1st

You can use CronExpr independently of the supervisor for custom scheduling logic:

const zzz_jobs = @import("zzz_jobs");
const expr = try zzz_jobs.CronExpr.parse("0 9 * * 1-5");
// Check if a specific Unix timestamp matches
const matches = expr.matches(timestamp);
// Find the next matching timestamp after a given time
const next = try expr.nextAfter(timestamp);
MethodSignatureDescription
parsefn ([]const u8) !CronExprParse a 5-field cron string into a bitmask representation
matchesfn (CronExpr, i64) boolTest if a Unix timestamp matches the expression (UTC)
nextAfterfn (CronExpr, i64) !i64Find the next matching timestamp after the given time (UTC)
matchesWithOffsetfn (CronExpr, i64, i32) boolTest with a fixed timezone offset (seconds from UTC)
nextAfterWithOffsetfn (CronExpr, i64, i32) !i64Find next match with a timezone offset

By default, cron expressions are evaluated against UTC. For local-time scheduling, the cron scheduler and CronExpr support a fixed timezone offset specified in seconds from UTC:

// The CronScheduler accepts a timezone offset at initialization:
// tz_offset is in seconds: EST = -18000, JST = 32400
var scheduler = zzz_jobs.CronScheduler(zzz_jobs.MemoryStore)
.initWithTimezone(&store, &running, -5 * 3600); // EST

The timezone offset is a fixed value and does not account for daylight saving time transitions. For DST-aware scheduling, adjust the offset seasonally in your application code.

To prevent overlapping runs of a slow cron job, combine cron scheduling with unique job keys:

try supervisor.registerCron(
"hourly_sync",
"0 * * * *",
"sync_worker",
"{}",
.{
.unique_key = "hourly-sync",
.unique_strategy = .ignore_new,
},
);

If the previous sync is still running (or pending) when the next cron tick fires, the new job is silently skipped because a job with the same unique_key already exists in a non-terminal state.

If a cron expression is invalid, CronExpr.parse returns error.InvalidCronExpression. This propagates through registerCron, so you can handle it at registration time:

supervisor.registerCron("bad", "invalid expr", "worker", "{}", .{}) catch |err| {
std.log.err("Invalid cron expression: {}", .{err});
};

If the scheduler has reached its limit of 32 entries, registerCron returns error.TooManyCronEntries.