Clockwork
Stored schedules that start batch jobs
Clockwork is the scheduler of a Korall Engineering™ node. It keeps a book of stored schedules. Each schedule has a time rule and a batch job type. At each time of the rule, clockwork submits one batch job, and the batch system does the work.
Clockwork remembers what it fired, what it missed and how each run ended. So it can wait for the last run before it starts the next. After the node was off, it knows which runs it missed, and it starts them at once.
Every node has clockwork: Hub, Agent and Remote.
The schedule
| Field | Type | Default | Meaning |
|---|---|---|---|
id |
text | issued | The book issues the id, as cw-3. The book never issues one id twice |
name |
text | empty | What the operator reads. It does not have to be unique |
rule |
text | ā | The time rule, in one of the two syntaxes |
zone |
IANA name | the zone of the computer | The zone of the rule, as Europe/Oslo. The schedule stores the name, so a move of the computer to another zone does not move the schedule |
command |
batch job type | ā | What a firing submits |
arguments |
JSON object | empty | The data of the batch job |
enabled |
bool | true | Off: no firing, and no missed run is counted |
overlap |
skip, wait-for-last, parallel |
wait-for-last |
What a firing does while the last run is busy. See Overlap |
catchUp |
none, once, all |
all |
What starts for the runs that the node missed. See Catch-up |
catchUpMax |
1 to 1000 | 10 | The most missed runs that all starts |
jitterSeconds |
0 to 3600 | 0 | A delay from 0 to this many seconds, drawn once for each firing |
A schedule also keeps its state: the last time that it dealt with, the next time, the firings that wait, the batch job that is busy, and its last 200 runs.
The two syntaxes
Cron
Five fields: minute, hour, day of month, month and day of the week.
| Field | Values |
|---|---|
| minute | 0 to 59 |
| hour | 0 to 23 |
| day of month | 1 to 31 |
| month | 1 to 12, or jan to dec |
| day of the week | 0 to 7, or sun to sat. 0 and 7 are both Sunday |
Each field takes *, a number, a range a-b, a list a,b,c, and a step */n
or a-b/n. A name can be in any case.
When the day of month and the day of the week both start with something other
than *, a day matches when either one matches. When one of them starts with
*, both must match. This is the rule of Vixie cron.
| Rule | Fires |
|---|---|
0 2 * * * |
Every day at 02:00 |
*/15 * * * * |
At minutes 0, 15, 30 and 45 of every hour |
30 8 * * mon-fri |
At 08:30 from Monday to Friday |
0 0 13 * fri |
At 00:00 on the 13th of each month and on each Friday |
0 0 1 */3 * |
At 00:00 on 1 January, 1 April, 1 July and 1 October |
The short forms:
| Short form | Means |
|---|---|
@hourly |
0 * * * * |
@daily, @midnight |
0 0 * * * |
@weekly |
0 0 * * 0 |
@monthly |
0 0 1 * * |
@yearly, @annually |
0 0 1 1 * |
Every
every(N).unit, then optionally .at(HH:MM) and .jitter(S).
| Unit | Fires |
|---|---|
seconds, minutes, hours |
Every N units from the creation of the schedule. A change to or from summer time does not move it |
days |
Every N days at the local time of .at(). Without .at(), at the minute of the creation |
weeks |
Every N weeks, on the weekday of the first firing, at the local time of .at() |
The singular forms (minute, day) are the same units. .at() is only for
days and weeks. .jitter(S) sets the jitter, from 0 to 3600 seconds. A rule
that sets .jitter() needs the jitter field of the schedule at 0.
| Rule | Fires |
|---|---|
every(15).minutes |
Every 15 minutes |
every(1).days.at(02:00) |
Every day at 02:00 |
every(2).weeks.at(18:00) |
Every second week, on the weekday of the first firing, at 18:00 |
every(1).hours.jitter(60) |
Every hour, up to 60 seconds late |
Refused rules
Clockwork refuses a rule that it cannot fire, and the refusal names the field and the reason. The editor, the OPAL tasks and the book use the one parser.
| Rule | Refusal |
|---|---|
| empty | rule: the rule is empty |
0 0 30 2 * |
day of month: the rule never fires: none of its months has such a day |
60 * * * * |
minute: 60 is out of range: the field takes 0 to 59 |
0 0 * * * * |
rule: a cron rule has five fields, and this one has six. Clockwork takes no seconds field: the finest step is one minute |
@reboot |
rule: @reboot is an event, not a time. Clockwork fires at times |
every(0).minutes |
every: every(0) never fires: N must be 1 or more |
every(3).fortnights |
unit: `fortnights` is not a unit. Use seconds, minutes, hours, days or weeks |
Time, zones and summer time
Clockwork evaluates a cron rule and an .at() rule in the zone of the
schedule. It evaluates an elapsed rule in UTC. It stores every time in UTC.
| Case | Example | What fires |
|---|---|---|
| A local time that the change to summer time skips | 02:30 in Oslo on the last Sunday of March | One firing, at the first instant after the gap: 03:00 |
| A local time that the change to winter time repeats | 02:30 in Oslo on the last Sunday of October | One firing, at the first 02:30 |
| A rule that matches each minute of a repeated hour | */15 2 * * * |
Each quarter fires once. The second pass of the hour fires nothing |
These rules are the rules of Vixie cron.
A nominal time is the time that the rule names. An actual time is the time when clockwork submitted the job. The two differ by the jitter, by a wait for the last run, and by a catch-up. Clockwork counts nominal times.
A firing
Each firing becomes one batch job:
| Job field | Value |
|---|---|
type |
The schedule's command |
data |
The schedule's arguments, and clockwork = {schedule, nominal, reason} |
source |
clockwork:<id>, or clockwork-manual:<id> for a run now |
originType, originId |
system, and the id of the schedule |
displayLabel |
The name of the schedule and the nominal time, as Nightly (2026-10-18 02:00) |
correlationKey |
clockwork:<id>:<nominal time>, or clockwork-manual:<id>:<time> |
reason is scheduled, catch-up or manual. A job of a catch-up carries
its own nominal time, so a job that does the work of one period can use it.
Clockwork submits through submitOrGet(). If the queue has a job with the same
correlation key, it returns that job, so no second job starts for one nominal
time. That holds after a crash between the submit and the save of the book.
Overlap
A run is busy from its submit until its batch job is done, failed, stopped or canceled.
overlap |
A firing while the last run is busy |
|---|---|
skip |
Is not submitted. The history records it as skipped-busy |
wait-for-last |
Waits. When the busy job ends, the oldest waiting firing starts at once |
parallel |
Is submitted at once |
Under wait-for-last, one scheduled firing waits at most. A newer one takes
its place, and the history records the older one as coalesced. The firings
of a catch-up and of a run now keep their place.
A waiting firing keeps the command and the arguments that the schedule had when the firing became due. An edit of the schedule changes the later firings.
A job that no worker runs stays pending in the queue. Its run stays busy, so the next firing waits or is skipped. The Batch screen says why no worker takes the job.
Catch-up
When the node was off, or the computer slept, or the clock jumped forward by more than one period, clockwork finds the runs that it missed.
catchUp |
What starts | What the history records |
|---|---|---|
none |
Nothing | Each missed time as missed |
once |
One run, for the newest missed time | The others as missed-merged |
all |
One run for each missed time, oldest first, at most catchUpMax |
The times over the cap as missed-over-cap. The newest times run |
The runs of a catch-up go through the overlap policy, so under wait-for-last
they run one after another. They take no jitter.
Example: a schedule fires every day at 02:00. The node is off from the 13th at 20:00 to the 17th at 09:00. At the start, clockwork submits the run of the 14th. When it ends, it submits the run of the 15th, then the 16th, then the 17th. The next firing is the 18th at 02:00.
A clock that goes back fires nothing again. The next firing is the first nominal time after the last one that clockwork dealt with.
A disabled schedule counts no missed run. When it is enabled again, it starts with its next time.
The book and the lock
The book is the file clockwork.json in the base folder of the node.
- Each write is the whole file, so a crash leaves the old file or the new one.
- A book of a newer schema version is refused. Nothing fires until a clockwork that reads that version runs, and the file is not changed.
- A book that does not parse is moved aside as
clockwork.json.broken-<time>, and the book starts empty.
Two nodes can share one base folder. The file clockwork.lock in the folder
decides which node fires. The other node shows the schedules read-only, names
the node that fires them, and tries the lock again every 60 seconds. When the
first node ends, the second one takes over.
The node switch
The setting clockwork.enabled, "Run stored schedules", is in the
node schema. Off: no schedule fires,
and the time that it is off counts no missed run.
The OPAL tasks
| Task | Does | Grant | Confirmation |
|---|---|---|---|
clockwork.list |
The schedules, with the rule in words, the next time and the last outcome | none | no |
clockwork.get id=<id> |
One schedule, its rule in words and its next 5 times | none | no |
clockwork.history id=<id> [limit=50] |
The runs of one schedule, newest first | none | no |
clockwork.preview rule=<rule> [zone=] [count=5] [after=] |
The next times of a rule in a zone, with no schedule | none | no |
clockwork.put [id=<id>] <fields> |
Create a schedule, or change its fields. reset=1 brings back the values of a seeded schedule |
clockwork.write |
once per session |
clockwork.pause id=<id> |
Pause one schedule | clockwork.write |
no |
clockwork.resume id=<id> |
Resume one schedule | clockwork.write |
no |
clockwork.run-now id=<id> |
Fire one schedule now, once | clockwork.run |
every call |
clockwork.delete id=<id> |
Delete one schedule. A busy job runs to its end | clockwork.write |
every call |
clockwork.put takes the fields of the schedule by name. A
refused rule answers with the field and the reason of the parser:
{"task": "clockwork.put", "args": {"name": "Nightly audit", "rule": "0 2 * * *", "command": "trove.recurring.run", "arguments": {"spec": "wfc7wjvxtnw"}}}
Run outcomes
| Outcome | Means |
|---|---|
submitted |
The batch job runs, or waits in the queue |
done, failed, stopped, canceled |
The batch job ended so |
skipped-busy |
Under skip, the last run was busy |
coalesced |
Under wait-for-last, a newer firing took its place |
missed |
Under catch-up none |
missed-merged |
Under catch-up once, a newer missed time ran for it |
missed-over-cap |
Under catch-up all, over catchUpMax |
suspended |
The zone or the rule is not one that this computer can use. The schedule fires again when it is |