Skip to content
SHC Docs

Schedule module

The schedule module is SHC’s cron layer: it owns the schedules table, the reconcile loop that mirrors app.yaml’s crons: block into that table, and the dispatcher (inside the task queue) that actually fires jobs on-schedule.

Source: modules/schedule/ (the app sync lives in modules/schedule/service.go::SyncApp)

  • the dispatcher slice of subsystems/impl/queue.go.
  1. Schema-backed schedules. One row per (tenant, stack, env, job) in the schedules table. Uniqueness is enforced by uq_schedule_job.
  2. CRUD API. ScheduleService exposes create / get / list / update / enable / disable / remove + record_run.
  3. App sync. SyncApp() in modules/schedule/service.go reconciles the table against the stack’s declared crons: on every install and upgrade, without trampling user-local edits.
  4. Cron parsing. utils.ParseCron accepts standard 5-field cron, @hourly/@daily/…, every N (minute|hour|day|…), every day at HH:MM, and at HH:MM. Invalid input raises X400003INVSCH.
  5. Dispatch. The queue subsystem registers each enabled schedule with APScheduler on leader start-up, then enqueues the corresponding job when the trigger fires.
  6. Catch-up. On leader takeover, any schedule whose next_run is already in the past is fired once immediately, then resumed on its normal cadence.

Schedules carry a source field that controls the reconcile rules:

sourceCreated byApp sync behavior
appsync_schedules from app.yamlCreated, cron updated, and removed when the app drops the entry
usershc schedule addNever touched by app sync — operator-owned
systeminternal (e.g. health, quota ticks)Managed by its creator; not reconciled against app definitions

shc schedule itself supports add, ls, info, enable, disable, rm. There’s deliberately no set command — to change a cron expression on a user schedule, rm and add again. App sync will not silently overwrite anything: it only touches rows whose source is "app".

SyncApp(ctx, stackName, environment, crons) runs via POST /api/method/shc.schedule.sync:

  • during the install flow after jobs are registered
  • on uninstall with an empty crons map, purging the stack’s app rows

Its contract (see the docstring on modules/schedule/service.go::SyncApp):

  1. Creates a source="app" row for every cron: entry that doesn’t yet exist.
  2. Updates the cron expression on existing source="app" rows when the app’s declared expression changes.
  3. Removes source="app" rows whose job name the app no longer declares.

Rows with source="user" or source="system" are skipped entirely.

Concurrent sync calls from two controllers are safe: X409900SCHDPL on create and X404902SCHNFD on remove are both swallowed as idempotent.

The schedule module itself does not run timers. The scheduler lives on the leader only, inside subsystems/impl/queue.go. The relevant methods:

  • LoadSchedules() — called on leader start / takeover. Loads every enabled=True row, calls registerDBSchedule for each, and triggers catchupMissed for rows with next_run < now.
  • registerDBSchedule(schedule) — builds a stable job id schedule:<tenant>:<stack>:<job> and registers the job with coalesce=true, maxInstances=1 so backlogs collapse into a single run and overlapping executions are prevented.
  • executeSchedule(...) — the scheduler callback. Re-reads the row (in case it was disabled between registration and fire), updates last_run, recomputes next_run, emits V200610SCHTRG, and enqueues the named job with source "schedule".

Followers do not run the scheduler; on leadership change the new leader calls LoadSchedules() and registers everything afresh. That means clock drift between nodes doesn’t multiply fires.

All forms go through ParseCron:

# standard 5-field (UTC)
0 2 * * *
*/15 * * * *
# special strings
@hourly @daily @midnight @weekly @monthly @yearly @annually
# intervals
every 5 minutes
every 2 hours
every 1 day
# fixed time of day
at 02:30
every day at 03:15

Invalid input raises X400003INVSCH; callers in the schedule module surface it as X400001CRNINV with the offending string attached.

All times are interpreted in UTC — there is no per-tenant timezone setting.

ScheduleModel (modules/schedule/models.go):

ColumnPurpose
idUUID PK
tenant_namescope
stack_namescope
environmentscope
jobjob name (must exist as a *.job.yaml in the stack)
cronnormalized cron expression
enabledgates registration by load_schedules
sourceapp / user / system — drives reconcile behavior
last_runset by _execute_schedule after enqueue
next_runcomputed each fire; cleared on disable
created_atrow insert time

Unique constraint: (tenant_name, stack_name, environment, job).

  • V200610SCHTRG — schedule fired, about to enqueue the job
  • X200900SCHCAL — next-run calculation failed (invalid cron, scheduler disables the row rather than crashing the loop)

Event codes follow the same X<HTTP><SEQ><MNEMONIC> scheme as errors; see reference/errors and event lifecycle.

CodeRaised when
X400001CRNINVcron string rejected by parse_cron
X404902SCHNFDschedule row not found for (tenant, stack, env, job)
X409900SCHDPLunique constraint collision — row already exists
X200900SCHCALAPScheduler could not compute a next fire time

See reference/errors for exit codes and full messages.