# cron

> Describe when something should run with five-field cron schedules, work out the next run, and run jobs from a background scheduler.

This is the Markdown version of https://osl-lang.org/packages/cron. Every page of the OSL docs has one: add .md to its path. https://osl-lang.org/llms.txt lists them all.

```osl
import "std:cron"
```

A cron schedule is a short text that says when something should run: every fifteen minutes, at 09:00 on weekdays, on the first of the month. `std:cron` parses those schedules, tells you when one next fires, and runs functions on them from a scheduler in the background.

## Reading a schedule

A schedule has five fields separated by spaces: minute, hour, day of month, month and day of week (0 or 7 is Sunday, 1 is Monday). Each field is one of:

- `*`, every value.
- A number, such as `9`.
- A range, such as `9-17`.
- A step, such as `*/15` (every 15th) or `10-50/10`.
- A comma-separated list of those, such as `0,30`.

When both day fields are restricted, a day matches if either one does. Times are local time.

`cron.isValid(schedule)` says whether a schedule parses and fires at least once:

```osl
import "std:cron"

string[] schedules = [
  "* * * * *", "*/15 * * * *", "0 9-17 * * 1-5", "0 0 1 1 *", "60 * * * *", "* * * *", "0 0 31 2 *"
]
for schedule in schedules (
  log schedule cron.isValid(schedule)
)
```

Output:

```text
* * * * * true
*/15 * * * * true
0 9-17 * * 1-5 true
0 0 1 1 * true
60 * * * * false
* * * * false
0 0 31 2 * false
```

`60` is not a minute, the sixth schedule has four fields, and February 31st never comes, so a schedule that can never fire is invalid too. Names such as `@daily` aren't supported: write `0 0 * * *`.

## When does it next run?

`cron.next(schedule, options)` returns when the schedule next fires after now, in unix milliseconds. The `from` option finds the next time after a moment you choose, which also makes a calculation repeatable:

```osl
import "std:cron"

number start = 1700000000000

number first = cron.next("*/15 * * * *", {from: start})
number second = cron.next("*/15 * * * *", {from: first})

log first > start
log first - start <= 900000
log second - first
log first % 60000
```

Output:

```text
true
true
900000
0
```

A quarter-hour schedule fires at most 15 minutes after any moment, and exactly 15 minutes after the previous fire. A fire time is always on a whole minute. `next` throws a `ValueError` for an invalid schedule, so check with `isValid` when the text comes from a user.

## A scheduler with jobs

`cron.new()` makes a scheduler with no jobs. `add(name, schedule, fn)` registers a job. Each name is unique. `jobs()` describes every job, sorted by name:

```osl
import "std:cron"

auto scheduler = cron.new()
scheduler.add("backup", "0 3 * * *", def() -> (
  log "backing up"
))
scheduler.add("report", "0 9 * * 1-5", def() -> (
  log "sending report"
))

for job in scheduler.jobs() (
  log job.name job.schedule job.enabled job.runCount
)
```

Output:

```text
backup 0 3 * * * true 0
report 0 9 * * 1-5 true 0
```

Adding a name that exists, an invalid schedule, or something that isn't a function throws, so mistakes show up when the program starts rather than at three in the morning:

```osl
import "std:cron"

auto scheduler = cron.new()
scheduler.add("tidy", "0 * * * *", def() -> (
  log "tidying"
))

auto duplicate = try(scheduler.add("tidy", "30 * * * *", def() -> (
  log "again"
)))
log duplicate.unwrapErr().kind

auto bad = try(scheduler.add("other", "sometimes", def() -> (
  log "never"
)))
log bad.unwrapErr().kind
```

Output:

```text
ValueError
ValueError
```

## Running a job now

`run(name)` runs one job straight away, on the calling thread, and returns when it finishes. It works for disabled jobs too, and an error the job throws propagates to the caller. `runAll()` runs every enabled job one after another in name order. Both make the job's record update, so a scheduled job can be tested without waiting for its time:

```osl
import "std:cron"

auto scheduler = cron.new()
scheduler.add("b-second", "0 * * * *", def() -> (
  log "second job"
))
scheduler.add("a-first", "*/5 * * * *", def() -> (
  log "first job"
))

scheduler.runAll()
scheduler.run("a-first")

auto info = scheduler.job("a-first")
log info.runCount
log info.lastRun != null
log scheduler.job("b-second").runCount
```

Output:

```text
first job
second job
first job
2
true
1
```

`job(name)` returns an object with `name`, `schedule`, `enabled`, `running` (a run is in progress), `nextRun` and `lastRun` (unix milliseconds, `null` when not applicable) and `runCount`. It returns `null` when there is no such job. `run` on an unknown job throws a `NotFoundError`.

## Enable, disable and remove

A disabled job doesn't run on its schedule and has no next run, but `run` still works. `remove` deletes a job, and removing one that doesn't exist does nothing:

```osl
import "std:cron"

auto scheduler = cron.new()
scheduler.add("sync", "*/10 * * * *", def() -> (
  log "syncing"
))

log scheduler.isEnabled("sync")
log scheduler.job("sync").nextRun != null

scheduler.disable("sync")
log scheduler.isEnabled("sync")
log scheduler.job("sync").nextRun

scheduler.enable("sync")
log scheduler.isEnabled("sync")

scheduler.remove("sync")
scheduler.remove("sync")
log scheduler.has("sync")
log scheduler.jobs().len
```

Output:

```text
true
true
false
null
true
false
0
```

`clear()` removes every job.

## Starting the scheduler

`start()` runs enabled jobs in the background whenever their schedule matches. It checks every second, runs each due job on its own thread, and keeps going until `stop()`. `isRunning()` says whether it is started. Starting a running scheduler, or stopping a stopped one, does nothing:

```osl
import "std:cron"

auto scheduler = cron.new()
scheduler.add("tick", "* * * * *", def() -> (
  log "tick"
))

log scheduler.isRunning()
scheduler.start()
log scheduler.isRunning()
scheduler.stop()
log scheduler.isRunning()
```

Output:

```text
false
true
false
```

A job that is still running when it is due again is skipped, so a slow job never overlaps itself. A job that throws has its error printed and the scheduler keeps going. Runs already in progress finish after `stop`.

The program still ends when its main code ends, so a program that exists to run jobs must be kept alive. This one never returns, so it only compiles here:

```osl
import "std:cron"
import "std:thread"

auto scheduler = cron.new()
scheduler.add("cleanup", "0 3 * * *", def() -> (
  log "cleaning up old files"
))
scheduler.add("heartbeat", "* * * * *", def() -> (
  log "still alive"
))

scheduler.start()
log "scheduler started, next cleanup at " ++ scheduler.job("cleanup").nextRun

while true (
  thread.sleep(1000)
)
```

## Keeping a job short

Each run goes on a thread of its own. A job that can fail should handle its own failures, so nothing is lost in the scheduler's error printing:

```osl
import "std:cron"
import "std:retry"

string[] done = []

auto scheduler = cron.new()
scheduler.add("download", "0 * * * *", def() -> (
  auto outcome = retry.call(def(int attempt) string (
    if attempt < 2 (
      throw error("NetError", "busy")
    )
    return "downloaded"
  ), {delay: 1})
  done.append(outcome.unwrapOr("gave up"))
))

scheduler.run("download")
log done
```

Output:

```text
["downloaded"]
```

See [`std:retry`](/packages/retry.md) for the retry options, and [`std:thread`](/packages/thread.md) for running work on threads.

**Quiz.** When does a job with the schedule `0 9 * * 1-5` run?

- [ ] Every five minutes, from 9 to 1.
- [x] At 09:00 local time, Monday to Friday.
- [ ] On the 9th of every month.

Answer: The fields are minute, hour, day of month, month and day of week, so this is minute 0 of hour 9 on weekdays 1 to 5.

**Quiz.** What does `cron.isValid("0 0 31 2 *")` return?

- [ ] `true`, because every field is in range.
- [x] `false`, because February 31st never comes.
- [ ] It throws a `ValueError`.

Answer: A schedule that can never fire is invalid, and `isValid` reports that rather than throwing.

### Exercise: Run a job on demand

Add a job named `"hello"` that runs every five minutes and logs `hi`. Run it twice with `run`, then log its `runCount`.

Starting code:

```osl
import "std:cron"

auto scheduler = cron.new()

log scheduler.job("hello").runCount
```

It should print:

```text
hi
hi
2
```

Hint 1: `scheduler.add(name, schedule, fn)` registers the job. The schedule is `"*/5 * * * *"`.

Hint 2: `scheduler.run("hello")` runs it immediately, and each run adds one to `runCount`.

A solution:

```osl
import "std:cron"

auto scheduler = cron.new()
scheduler.add("hello", "*/5 * * * *", def() -> (
  log "hi"
))

scheduler.run("hello")
scheduler.run("hello")
log scheduler.job("hello").runCount
```

## API reference

## API reference

Every member of `std:cron`, generated from the source of OSL.

### Functions

#### `cron.addJob(any name, any schedule, any callback) boolean`

**Deprecated.** Deprecated: use cron.new() and scheduler.add(name, schedule, fn) instead.

addJob adds a job to the shared scheduler and reports false for an
invalid schedule or a duplicate name.

#### `cron.clear()`

**Deprecated.** Deprecated: use cron.new() and scheduler.clear() instead.

clear removes every job of the shared scheduler.

#### `cron.create() *cron.Cron`

**Deprecated.** Deprecated: use cron.new() instead.

create returns a new scheduler.

#### `cron.disableJob(any name) boolean`

**Deprecated.** Deprecated: use cron.new() and scheduler.disable(name) instead.

disableJob disables a job of the shared scheduler.

#### `cron.enableJob(any name) boolean`

**Deprecated.** Deprecated: use cron.new() and scheduler.enable(name) instead.

enableJob enables a job of the shared scheduler.

#### `cron.getJob(any name) object`

**Deprecated.** Deprecated: use cron.new() and scheduler.job(name) instead.

getJob describes a job of the shared scheduler.

#### `cron.getJobCount() int`

**Deprecated.** Deprecated: use cron.new() and scheduler.jobs().len instead.

getJobCount counts the shared scheduler's jobs.

#### `cron.getJobs() array`

**Deprecated.** Deprecated: use cron.new() and scheduler.jobs() instead.

getJobs describes the shared scheduler's jobs.

#### `cron.getLastRun(any name) int64`

**Deprecated.** Deprecated: use cron.new() and scheduler.job(name).lastRun instead.

getLastRun returns a shared job's last run in unix seconds, or 0.

#### `cron.getNextRun(any name) int64`

**Deprecated.** Deprecated: use cron.new() and scheduler.job(name).nextRun instead.

getNextRun returns a shared job's next run in unix seconds, or 0.

#### `cron.getRunCount(any name) int`

**Deprecated.** Deprecated: use cron.new() and scheduler.job(name).runCount instead.

getRunCount returns how often a shared job has run, or 0.

#### `cron.isEnabled(any name) boolean`

**Deprecated.** Deprecated: use cron.new() and scheduler.isEnabled(name) instead.

isEnabled reports whether a job of the shared scheduler is enabled.

#### `cron.isValid(string schedule) boolean`

isValid reports whether schedule is a valid five-field schedule that
fires at least once.

#### `cron.new() *cron.Scheduler`

new returns a Scheduler with no jobs. Add jobs with add, then call start
to run them in the background.

#### `cron.next(string schedule, object? options) number`

next returns when schedule next fires after now, in unix milliseconds.
Options: from (unix ms) finds the next time after that instead. Throws
ValueError when the schedule is invalid or never fires.

#### `cron.removeJob(any name) boolean`

**Deprecated.** Deprecated: use cron.new() and scheduler.remove(name) instead.

removeJob removes a job from the shared scheduler.

#### `cron.runAll() object`

**Deprecated.** Deprecated: use cron.new() and scheduler.runAll() instead.

runAll runs every job of the shared scheduler.

#### `cron.runJob(any name) boolean`

**Deprecated.** Deprecated: use cron.new() and scheduler.run(name) instead.

runJob runs a job of the shared scheduler and reports whether it succeeded.

#### `cron.runOnce() object`

**Deprecated.** Deprecated: use cron.new() and scheduler.runAll() instead.

runOnce runs every job of the shared scheduler.

#### `cron.start() *cron.Ticker`

**Deprecated.** Deprecated: use cron.new() and scheduler.start() instead.

start runs the shared scheduler in the background.

#### `cron.stop(*cron.Ticker done)`

**Deprecated.** Deprecated: use cron.new() and scheduler.stop() instead.

stop stops a background run started with start.

#### `cron.validateSchedule(any schedule) boolean`

**Deprecated.** Deprecated: use cron.isValid(schedule) instead.

validateSchedule reports whether schedule parses and can fire.

### `*cron.Scheduler`

#### `scheduler.add(string name, string schedule, any fn)`

add registers fn to run on schedule under name, enabled. Throws ValueError
when the schedule is invalid or never fires or a job already has that
name, and TypeError when fn isn't a function.

#### `scheduler.clear()`

clear removes every job.

#### `scheduler.disable(string name)`

disable stops the job called name from running on its schedule; run can
still run it. Throws NotFoundError when there is no such job.

#### `scheduler.enable(string name)`

enable lets the job called name run on its schedule again, from its next
matching time. Throws NotFoundError when there is no such job.

#### `scheduler.has(string name) boolean`

has reports whether a job called name exists.

#### `scheduler.isEnabled(string name) boolean`

isEnabled reports whether a job called name exists and is enabled.

#### `scheduler.isRunning() boolean`

isRunning reports whether start has been called without a later stop.

#### `scheduler.job(string name) {enabled: boolean, lastRun: int?, name: string, nextRun: int?, runCount: int, running: boolean, schedule: string}?`

job describes the job called name, or returns null when there is none.
Keys: name, schedule, enabled, running (a run is in progress), nextRun
(unix ms, null while disabled), lastRun (unix ms, null before the first
run) and runCount.

#### `scheduler.jobs() {enabled: boolean, lastRun: int?, name: string, nextRun: int?, runCount: int, running: boolean, schedule: string}[]`

jobs describes every job, sorted by name, with the keys job returns.

#### `scheduler.remove(string name)`

remove deletes the job called name; removing a job that doesn't exist
does nothing. A run already in progress finishes.

#### `scheduler.run(string name)`

run runs the job called name now, on the calling thread, even when it is
disabled, and returns when it finishes. An error the job throws
propagates. Throws NotFoundError when there is no such job.

#### `scheduler.runAll()`

runAll runs every enabled job now, one after another in name order, on
the calling thread. Every job runs even if an earlier one throws; then
the first error is rethrown.

#### `scheduler.start()`

start runs enabled jobs in the background whenever their schedule
matches, checking every second, until stop is called. Starting a running
scheduler does nothing. A job that is still running when it is due again
is skipped, and a job that throws has its error printed while the
scheduler keeps running. The program still exits when its main code
ends, so keep it alive (for example with a thread that waits) while jobs
should run.

#### `scheduler.stop()`

stop stops running jobs on their schedules; runs in progress finish.
Stopping a scheduler that isn't running does nothing.

### `*cron.Cron`

#### `cron.addJob(any name, any schedule, any callback) boolean`

**Deprecated.** Deprecated: use cron.new() and scheduler.add(name, schedule, fn) instead.

addJob adds a job and reports false for an invalid schedule or a
duplicate name.

#### `cron.clear()`

**Deprecated.** Deprecated: use cron.new() and scheduler.clear() instead.

clear removes every job.

#### `cron.disableJob(any name) boolean`

**Deprecated.** Deprecated: use cron.new() and scheduler.disable(name) instead.

disableJob disables a job and reports whether it existed.

#### `cron.enableJob(any name) boolean`

**Deprecated.** Deprecated: use cron.new() and scheduler.enable(name) instead.

enableJob enables a job and reports whether it existed.

#### `cron.getJob(any name) object`

**Deprecated.** Deprecated: use cron.new() and scheduler.job(name) instead.

getJob describes one job, or returns null.

#### `cron.getJobCount() int`

**Deprecated.** Deprecated: use cron.new() and scheduler.jobs().len instead.

getJobCount counts the jobs.

#### `cron.getJobs() array`

**Deprecated.** Deprecated: use cron.new() and scheduler.jobs() instead.

getJobs describes every job as {name, schedule, enabled, nextRun,
lastRun, runCount} objects with times in unix seconds.

#### `cron.getLastRun(any name) int64`

**Deprecated.** Deprecated: use cron.new() and scheduler.job(name).lastRun instead.

getLastRun returns a job's last run in unix seconds, or 0.

#### `cron.getNextRun(any name) int64`

**Deprecated.** Deprecated: use cron.new() and scheduler.job(name).nextRun instead.

getNextRun returns a job's next run in unix seconds, or 0.

#### `cron.getRunCount(any name) int`

**Deprecated.** Deprecated: use cron.new() and scheduler.job(name).runCount instead.

getRunCount returns how often a job has run, or 0.

#### `cron.isEnabled(any name) boolean`

**Deprecated.** Deprecated: use cron.new() and scheduler.isEnabled(name) instead.

isEnabled reports whether a job exists and is enabled.

#### `cron.removeJob(any name) boolean`

**Deprecated.** Deprecated: use cron.new() and scheduler.remove(name) instead.

removeJob removes a job and reports whether it existed.

#### `cron.runAll() object`

**Deprecated.** Deprecated: use cron.new() and scheduler.runAll() instead.

runAll runs every job and returns an object of {success, duration,
count} keyed by job name.

#### `cron.runJob(any name) boolean`

**Deprecated.** Deprecated: use cron.new() and scheduler.run(name) instead.

runJob runs an enabled job and reports whether it ran without throwing.

#### `cron.runOnce() object`

**Deprecated.** Deprecated: use cron.new() and scheduler.runAll() instead.

runOnce runs every job.

#### `cron.start() *cron.Ticker`

**Deprecated.** Deprecated: use cron.new() and scheduler.start() instead.

start checks for due jobs every second in the background and returns a
token for stop.

#### `cron.stop(*cron.Ticker done)`

**Deprecated.** Deprecated: use cron.new() and scheduler.stop() instead.

stop stops the background checks start began; stopping twice does nothing.

#### `cron.validateSchedule(any schedule) boolean`

**Deprecated.** Deprecated: use cron.isValid(schedule) instead.

validateSchedule reports whether schedule parses and can fire.

### `*cron.Ticker`

A handle with no methods of its own: pass it to the package's functions.
