Skip to content

Scheduling

Cron jobs (also called periodic jobs or recurring jobs) execute on a repeating schedule. OJS defines a CronJob data structure, cron expression syntax, timezone handling, overlap prevention, and management endpoints. Each triggered occurrence creates a standard OJS job that follows the normal job lifecycle.

A CronJob is a named, persistent registration that tells the system to enqueue a job on a repeating schedule:

{
"name": "daily-report",
"cron": "0 9 * * *",
"timezone": "America/New_York",
"type": "report.generate",
"args": [{"report": "daily_summary"}],
"options": {
"queue": "reports",
"timeout": 300,
"retry": {
"max_attempts": 3,
"initial_interval": "PT30S",
"backoff_coefficient": 2.0
}
},
"overlap_policy": "skip",
"enabled": true,
"description": "Generate daily summary report at 9 AM ET"
}
FieldTypeDescription
namestringUnique identifier for this registration. Must match [a-z0-9][a-z0-9\-\.]*, max 255 characters.
cronstringCron expression or special expression defining the schedule.
typestringThe OJS job type to enqueue on each occurrence (e.g., "report.generate").
FieldTypeDefaultDescription
argsarray[]Arguments passed to each triggered job. JSON-native types only.
timezonestring"UTC"IANA timezone name for schedule evaluation.
optionsobject{}Job options applied to each triggered job (queue, timeout, retry, tags, meta).
overlap_policystring"skip"What to do when a new occurrence fires while the previous run is still active.
enabledbooleantrueWhether this cron job is active. Disabled jobs remain registered but do not fire.
descriptionstringnullHuman-readable description for documentation and dashboards.
FieldTypeDescription
last_run_atISO 8601Timestamp of the most recent trigger. null if never fired.
next_run_atISO 8601Next scheduled trigger. null if disabled.
run_countintegerTotal number of times this cron job has triggered.
created_atISO 8601When the cron job was registered.

The next_run_at field is computed on registration and updated after every trigger, letting operators verify schedules are correct without waiting for the next occurrence.

OJS uses the standard 5-field cron format with an optional 6th field for seconds.

┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12 or JAN-DEC)
│ │ │ │ ┌───────────── day of week (0-7 or SUN-SAT, 0 and 7 are Sunday)
│ │ │ │ │
* * * * *

Implementations may support a 6-field format where the first field is seconds (0-59). The system distinguishes 5-field from 6-field by counting whitespace-separated tokens. When using 5-field format, the seconds field is implicitly 0.

CharacterNameDescriptionExample
*WildcardMatches every value* * * * * = every minute
,ListMultiple values1,15 * * * * = minute 1 and 15
-RangeInclusive range1-5 * * * * = minutes 1 through 5
/StepIncrements*/15 * * * * = every 15 minutes
LLastLast day of month or last weekday0 0 L * * = midnight on last day of month
WWeekdayNearest weekday to given day0 0 15W * * = nearest weekday to the 15th
#NthNth weekday occurrence in month0 0 * * 5#3 = third Friday

The four basic characters (*, ,, -, /) must be supported in all fields. The L, W, and # characters should be supported for day-of-month and day-of-week fields.

Both numeric values and three-letter English abbreviations (case-insensitive) are accepted for months (JAN-DEC) and days of week (SUN-SAT).

Cron expressions must be validated at registration time. Invalid expressions are rejected with a 400 Bad Request response. Validation errors include values outside allowed ranges, step values of 0, and malformed syntax. Day-of-month values valid only in some months (like 31) are allowed but the job simply does not fire in months where the day does not exist.

These aliases must be supported:

ExpressionEquivalentDescription
@yearly or @annually0 0 1 1 *Once a year, midnight January 1st
@monthly0 0 1 * *Once a month, midnight on the 1st
@weekly0 0 * * 0Once a week, midnight on Sunday
@daily or @midnight0 0 * * *Once a day at midnight
@hourly0 * * * *Once an hour at minute 0

Implementations should support @every <duration> for interval-based scheduling:

@every 30m -- every 30 minutes
@every 2h -- every 2 hours
@every 1h30m -- every 1 hour 30 minutes
@every 45s -- every 45 seconds

An important distinction: @every 30m fires every 30 minutes from the time the cron job was registered or last triggered. */30 * * * * fires at minutes 0 and 30 of every hour regardless of registration time. These are different behaviors.

The timezone field must use IANA timezone database names (e.g., "America/New_York", "Europe/London", "Asia/Tokyo"). Fixed UTC offsets ("+05:00") and abbreviations ("EST") must be rejected because they do not encode daylight saving time transitions. The default timezone is "UTC".

Spring forward (clock jumps ahead): If a cron job is scheduled during the skipped hour, it must not fire. For example, 30 2 * * * (2:30 AM) in America/New_York on the spring-forward day simply does not fire because 2:30 AM does not exist.

Fall back (clock falls behind): If a cron job is scheduled during the repeated hour, it must fire exactly once during the first occurrence. For example, 30 1 * * * (1:30 AM) on the fall-back day fires once during the first 1:30 AM (EDT), not again during the second 1:30 AM (EST).

An overlap occurs when the next scheduled occurrence arrives while the previous run is still executing. The overlap_policy field controls the behavior.

Skip the new occurrence entirely. The cron job’s next_run_at advances to the following scheduled time. A cron.skipped event is emitted. This is the safest default because it prevents unbounded concurrency for long-running jobs.

Start a new run regardless. Multiple instances of the same cron job may execute simultaneously. Use only when concurrent runs are safe (e.g., stateless health checks).

Cancel the active run and start a new one. The previous job transitions to cancelled. Useful for “latest data wins” scenarios like cache rebuilds.

Enqueue the new run even if the previous one is active, but do not allow concurrent execution. The new job waits in the queue. If overlap persists across multiple occurrences, jobs accumulate. Implementations should warn when more than 2 queued occurrences exist.

In distributed deployments, only one server instance must evaluate cron schedules at any given time. Without this, every instance would independently trigger jobs, producing duplicates proportional to the number of instances.

Requirements:

  • At most one instance evaluates cron schedules at a time (the “cron leader”).
  • Automatic failover must be supported. If the leader fails, another instance must acquire leadership within 30 seconds (recommended).
  • The leader must periodically renew its claim. If renewal fails, it must stop evaluating and allow another instance to take over.

Common implementation strategies include PostgreSQL advisory locks, Redis distributed locks (SET NX EX), and consensus protocols (Raft, etcd leases).

The cron leader runs a loop that:

  1. Computes the next occurrence for each enabled cron job.
  2. For any job whose next_run_at has arrived, evaluates the overlap policy and enqueues if appropriate.
  3. Updates last_run_at, next_run_at, and run_count.
  4. Sleeps until the next earliest next_run_at (or a maximum of 60 seconds).

If the leader wakes up after a pause and finds multiple missed occurrences, it fires at most one catch-up occurrence per cron job, then advances next_run_at to the next future time. This prevents a burst of jobs after a disruption.

HTTP: POST /ojs/v1/cron gRPC: RegisterCron

Registration uses upsert semantics: if a cron job with the given name already exists, its fields are updated. This enables declarative management where the application registers all cron jobs on startup.

Terminal window
curl -s -X POST https://jobs.example.com/ojs/v1/cron \
-H "Content-Type: application/openjobspec+json" \
-d '{
"name": "daily-report",
"cron": "0 9 * * *",
"timezone": "America/New_York",
"type": "report.generate",
"args": [{"report": "daily_summary"}],
"options": {
"queue": "reports",
"timeout": 300
},
"overlap_policy": "skip"
}'

Returns 201 Created for new registrations, 200 OK for updates.

HTTP: GET /ojs/v1/cron gRPC: ListCron

Returns all registered cron jobs with their system-managed fields. Filtering by enabled status is supported via ?enabled=true.

HTTP: GET /ojs/v1/cron/:name

Returns a single cron job by name. Returns 404 Not Found if it does not exist.

HTTP: DELETE /ojs/v1/cron/:name gRPC: UnregisterCron

Removes the cron registration. Jobs already enqueued by this schedule are not affected. Returns 404 Not Found if the cron job does not exist.

HTTP: PATCH /ojs/v1/cron/:name

Enable or disable a cron job without removing its registration:

{ "enabled": false }

When disabled, next_run_at is set to null, providing a clear signal that the cron job will not fire.

The cron subsystem emits two events:

Emitted when a cron job fires and a new job is enqueued:

{
"event": "cron.triggered",
"data": {
"cron_name": "daily-report",
"job_id": "019501a2-3b4c-7d5e-8f6a-1b2c3d4e5f6a",
"job_type": "report.generate",
"run_count": 43,
"scheduled_time": "2026-02-12T14:00:00Z",
"actual_time": "2026-02-12T14:00:00.123Z"
}
}

The delta between scheduled_time and actual_time reveals evaluation lag.

Emitted when an occurrence is skipped:

{
"event": "cron.skipped",
"data": {
"cron_name": "daily-report",
"reason": "overlap_skip",
"active_job_id": "019501a2-1111-2222-3333-444455556666",
"scheduled_time": "2026-02-12T14:00:00Z"
}
}

Skip reasons: overlap_skip (previous run still active), disabled (cron job was disabled), dst_skip (time does not exist due to spring-forward).

Skipped occurrences are invisible by default since no job is created and no handler runs. Without this event, operators cannot tell whether the cron job is working correctly or broken.

Weekday morning report:

{
"name": "weekday-morning-report",
"cron": "0 9 * * MON-FRI",
"timezone": "America/New_York",
"type": "report.generate",
"args": [{"report": "daily_summary", "format": "pdf"}],
"options": {
"queue": "reports",
"timeout": 600,
"retry": {"max_attempts": 3}
},
"overlap_policy": "skip"
}

Hourly cache cleanup:

{
"name": "cache-cleanup",
"cron": "@hourly",
"type": "maintenance.cache_cleanup",
"args": [{"max_age_hours": 24, "batch_size": 1000}],
"options": {
"queue": "maintenance",
"timeout": 120
},
"overlap_policy": "skip"
}

Every-5-minute health check with concurrent execution:

{
"name": "upstream-health-check",
"cron": "@every 5m",
"type": "monitoring.health_check",
"args": [{"targets": ["db", "cache", "search"]}],
"options": {
"queue": "monitoring",
"timeout": 30,
"retry": {"max_attempts": 1}
},
"overlap_policy": "allow"
}

Last day of month billing report:

{
"name": "monthly-billing",
"cron": "0 23 L * *",
"timezone": "America/Chicago",
"type": "billing.monthly_report",
"args": [{"include_pending": true}],
"options": {
"queue": "billing",
"timeout": 3600,
"retry": {"max_attempts": 3}
},
"overlap_policy": "cancel_previous"
}

Data sync with enqueue policy (every sync matters, no concurrency):

{
"name": "external-data-sync",
"cron": "*/30 * * * *",
"type": "integration.sync",
"args": [{"source": "api.partner.com", "endpoint": "/v2/orders"}],
"options": {
"queue": "integrations",
"timeout": 900,
"retry": {"max_attempts": 5}
},
"overlap_policy": "enqueue"
}