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.
Responsibilities
Section titled “Responsibilities”- Schema-backed schedules. One row per
(tenant, stack, env, job)in theschedulestable. Uniqueness is enforced byuq_schedule_job. - CRUD API.
ScheduleServiceexposes create / get / list / update / enable / disable / remove +record_run. - App sync.
SyncApp()inmodules/schedule/service.goreconciles the table against the stack’s declaredcrons:on every install and upgrade, without trampling user-local edits. - Cron parsing.
utils.ParseCronaccepts standard 5-field cron,@hourly/@daily/…,every N (minute|hour|day|…),every day at HH:MM, andat HH:MM. Invalid input raisesX400003INVSCH. - Dispatch. The queue subsystem registers each enabled schedule with APScheduler on leader start-up, then enqueues the corresponding job when the trigger fires.
- Catch-up. On leader takeover, any schedule whose
next_runis already in the past is fired once immediately, then resumed on its normal cadence.
Schedule sources
Section titled “Schedule sources”Schedules carry a source field that controls the reconcile rules:
source | Created by | App sync behavior |
|---|---|---|
app | sync_schedules from app.yaml | Created, cron updated, and removed when the app drops the entry |
user | shc schedule add | Never touched by app sync — operator-owned |
system | internal (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".
App sync — the reconcile contract
Section titled “App sync — the reconcile contract”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):
- Creates a
source="app"row for everycron:entry that doesn’t yet exist. - Updates the cron expression on existing
source="app"rows when the app’s declared expression changes. - 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.
Dispatch (inside the queue subsystem)
Section titled “Dispatch (inside the queue subsystem)”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 everyenabled=Truerow, callsregisterDBSchedulefor each, and triggerscatchupMissedfor rows withnext_run < now.registerDBSchedule(schedule)— builds a stable job idschedule:<tenant>:<stack>:<job>and registers the job withcoalesce=true, maxInstances=1so 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), updateslast_run, recomputesnext_run, emitsV200610SCHTRG, 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.
Cron grammar
Section titled “Cron grammar”All forms go through ParseCron:
# standard 5-field (UTC)0 2 * * **/15 * * * *
# special strings@hourly @daily @midnight @weekly @monthly @yearly @annually
# intervalsevery 5 minutesevery 2 hoursevery 1 day
# fixed time of dayat 02:30every day at 03:15Invalid 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.
Storage
Section titled “Storage”ScheduleModel (modules/schedule/models.go):
| Column | Purpose |
|---|---|
id | UUID PK |
tenant_name | scope |
stack_name | scope |
environment | scope |
job | job name (must exist as a *.job.yaml in the stack) |
cron | normalized cron expression |
enabled | gates registration by load_schedules |
source | app / user / system — drives reconcile behavior |
last_run | set by _execute_schedule after enqueue |
next_run | computed each fire; cleared on disable |
created_at | row insert time |
Unique constraint: (tenant_name, stack_name, environment, job).
Events
Section titled “Events”V200610SCHTRG— schedule fired, about to enqueue the jobX200900SCHCAL— 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.
Errors
Section titled “Errors”| Code | Raised when |
|---|---|
X400001CRNINV | cron string rejected by parse_cron |
X404902SCHNFD | schedule row not found for (tenant, stack, env, job) |
X409900SCHDPL | unique constraint collision — row already exists |
X200900SCHCAL | APScheduler could not compute a next fire time |
See reference/errors for exit codes and full messages.
CLI / API surface
Section titled “CLI / API surface”- CLI:
shc schedule {add,ls,info,enable,disable,rm}— see reference/cli/schedule - HTTP:
modules/schedule/router.goexposes the same CRUD under/api/v1/schedules/...— see reference/api/schedule
Related
Section titled “Related”- Jobs — what schedules fire
- Task queue — hosts the APScheduler instance
- App module — owns
sync_scheduleson install/upgrade - Flow: install — where app schedules are reconciled