Standard library · Time and concurrency

cron

Five-field cron expressions and a minute scheduler that runs each job on its own thread.

import cron

cron parses the usual five-field cron expression, matches it against a time, computes the next matching minute, and can run registered jobs once a minute. It is a library, not a service: it runs inside your program, uses your program's threads and stops when your program stops.

The five fields are minute, hour, day of month, month and day of week, in that order. Each field accepts *, a number, a range a-b, a list a,b,c, and a step */n or a-b/n. Months and weekdays also accept three-letter English names such as jan and mon. As in Vixie cron, when both day fields are restricted a time matches either of them; when one is *, both must match.

*/15 * * * *        every quarter hour
0 9-17 * * mon-fri  every hour from 9 to 17 on weekdays
30 2 1 * *          at 02:30 on the first day of every month
0 0 29 feb *        on February 29

The macros @yearly, @annually, @monthly, @weekly, @daily, @midnight and @hourly expand to the usual five fields.

Parsing and the next run

Parse returns an Expr with ok false when the text is not a valid expression. Check ok before you use it. Next returns the first minute strictly after the given time that matches, in the given time zone, or 0 when it finds none within the scan limit.

cron-quickstart.salam
import cron

const ZONE := "Invalid/Nowhere"
const OFF := 210
const AFTER := 1792800300 as i64

func show(spec: str):
    e := cron.Parse(spec)
    println spec, e.ok, cron.Next(e, AFTER, ZONE, OFF)
end

func main:
    show("*/20 * * * *")
    show("@daily")
    show("0 0 29 feb *")
    show("nope")

    e := cron.Parse("*/5 * * * *")
    println cron.MatchFields(e, 15, 10, 1, 1, 0)
    println cron.MatchFields(e, 16, 10, 1, 1, 0)
    println cron.OffsetSeconds(ZONE, OFF, AFTER)
end
Output*/20 * * * * true 1792800600 @daily true 1792873800 0 0 29 feb * true 1835382600 nope false 0 true false 12600

Times are UTC seconds since the epoch. Next asks the zone database for the offset at each candidate minute, so daylight saving changes are handled. When the zone does not exist, every call takes fallback_minutes instead, which is also what the tests use to stay independent of the machine.

Matching a time you already have

MatchFields takes the local minute, hour, day, month and weekday directly, so you can use it on values from your own clock or from a database. MatchesLocal takes a wall-clock instant in seconds, already shifted by the zone offset, and is what the scheduler uses internally.

cron-match.salam
import cron

func main:
    e := cron.Parse("0 9-17 * * mon-fri")
    println cron.MatchFields(e, 0, 9, 1, 1, 1)
    println cron.MatchFields(e, 30, 9, 1, 1, 1)
    println cron.MatchesLocal(e, 1793005200 as i64)
end
Outputtrue false true

Weekdays are 0 for Sunday through 6 for Saturday; 7 is accepted and treated as Sunday. MatchFields returns false for an expression whose ok is false.

Zones and offsets

The scheduler keeps one zone for all jobs. SetZone sets its name and the fallback offset in minutes, used when the name cannot be loaded. OffsetSeconds returns the offset of any zone at an instant, which is useful outside the scheduler too.

cron.SetZone("Asia/Tehran", 210)
off := cron.OffsetSeconds("Asia/Tehran", 210, 1700000000 as i64)
println off / (60 as i64)

The default zone is UTC with a fallback of 0. Call SetZone before Run or RunBackground; changing it later affects the next minute, not a job that is already running.

Jobs and the scheduler

Add registers a function under a name and an expression and returns the job id, or -1 when the expression does not parse. A job handler takes the job id as an i64. Register every job before you start the scheduler.

cron-schedule.salam
import mem
import time
import cron

mut hits := null as i64*

func tick(id: i64):
    atomic_add(hits, 1 as i64)
    println "job", id, cron.Name(id as int)
end

func main:
    hits = mem.AllocateZeroed(8 as u64) as i64*
    cron.SetZone("Invalid/Nowhere", 210)

    every := cron.Add("every minute", "* * * * *", tick)
    nightly := cron.Add("nightly", "0 3 * * *", tick)
    broken := cron.Add("broken", "nope", tick)
    println cron.Count(), cron.Name(every), cron.Name(nightly), broken
    println cron.IsBusy(every)

    started := cron.RunDue(1792800300 as i64)
    mut waited := 0
    until atomic_load(hits) == (0 as i64) or waited >= 200:
        time.Sleep(10)
        waited += 1
    end
    println "started", started, "hits", atomic_load(hits)
    println cron.IsBusy(every)

    cron.RunBackground()
    cron.Stop()
end

Run blocks and fires the jobs that are due once a minute; RunBackground runs the same loop on its own thread and returns at once. RunDue fires what is due at the given time without waiting for the next minute, which is what a test or a manual tick uses. In every form a job that is still running from an earlier firing is skipped, not queued, so a slow job cannot pile up.

Count, Name and IsBusy inspect the table. Stop asks the loop to end after the current job; it does not wait for a running job, and it can be called from a signal handler or another thread.

cron.SetZone("UTC", 0)
cron.Add("cleanup", "0 3 * * *", cleanup)
cron.RunBackground()
time.Sleep(60000)
cron.Stop()

What to know

  • Resolution is one minute. There are no seconds and no sub-minute schedules.
  • Jobs run on their own threads and share the process with you. A panic, a blocking read or a long loop in one job affects the whole program.
  • Stop is cooperative: the loop checks the flag between minutes and while sleeping, so a job that ignores its own cancellation can keep running.
  • The package does not persist jobs. If you need them after a restart, store the name and expression yourself and register them again at startup.
  • A day-of-month and day-of-week pair that are both restricted matches either field, as in Vixie cron. This is often surprising; prefer a single day field when you can.

Types

Expr

struct Expr
A parsed schedule: five bit masks, two flags that say which day field was a star, and an ok flag.
NameTypeDescription
minutesi64 = 0The minutes of the hour that match, as a bit mask.
hoursi64 = 0The hours of the day that match, as a bit mask.
daysi64 = 0The days of the month that match, as a bit mask.
monthsi64 = 0The months that match, as a bit mask.
weekdaysi64 = 0The weekdays that match, as a bit mask; Sunday is bit 0.
dom_starbool = trueTrue when the day-of-month field was *.
dow_starbool = trueTrue when the day-of-week field was *.
okbool = falseTrue when the text parsed; every other function refuses an expression whose ok is false.

Functions

Parse

func Parse(spec: str): Expr
Parses five fields or a macro such as @daily. Returns an expression with ok false on a bad field count, a number out of range, a broken range, a zero step or an unknown name.
cron-Parse.salam
import cron

const ZONE := "Invalid/Nowhere"
const OFF := 210
const AFTER := 1792800300 as i64

func show(spec: str):
    e := cron.Parse(spec)
    println spec, e.ok, cron.Next(e, AFTER, ZONE, OFF)
end

func main:
    show("*/20 * * * *")
    show("@daily")
    show("0 0 29 feb *")
    show("nope")

    e := cron.Parse("*/5 * * * *")
    println cron.MatchFields(e, 15, 10, 1, 1, 0)
    println cron.MatchFields(e, 16, 10, 1, 1, 0)
    println cron.OffsetSeconds(ZONE, OFF, AFTER)
end
Output*/20 * * * * true 1792800600 @daily true 1792873800 0 0 29 feb * true 1835382600 nope false 0 true false 12600

MatchFields

func MatchFields(e: Expr, minute: int, hour: int, day: int, month: int, weekday: int): bool
True when the local minute, hour, day, month and weekday match the expression. Weekdays are 0 for Sunday through 6 for Saturday.
cron-Match.salam
import cron

func main:
    e := cron.Parse("0 9-17 * * mon-fri")
    println cron.MatchFields(e, 0, 9, 1, 1, 1)
    println cron.MatchFields(e, 30, 9, 1, 1, 1)
    println cron.MatchesLocal(e, 1793005200 as i64)
end
Outputtrue false true

MatchesLocal

func MatchesLocal(e: Expr, local: i64): bool
True when a wall-clock instant in seconds, already shifted by the zone offset, matches the expression.

The example for this item is shown with the ones above: cron-Match.salam

OffsetSeconds

func OffsetSeconds(zone: str, fallback_minutes: int, epoch: i64): i64
The offset of a time zone at an instant, in seconds; the fallback in minutes when the zone cannot be loaded.

The example for this item is shown with the ones above: cron-Parse.salam

Next

func Next(e: Expr, after: i64, zone: str, fallback_minutes: int): i64
The first minute strictly after after that matches, in UTC seconds, or 0 when none is found within the scan limit.

The example for this item is shown with the ones above: cron-Parse.salam

SetZone

func SetZone(name: str, fallback_minutes: int)
Sets the zone name and fallback offset for the scheduler. The default is UTC with 0.

The example for this item is shown with the ones above: cron-Parse.salam

Add

func Add(name: str, spec: str, fn: i64): int
Registers a handler under a name and a schedule and returns the job id, or -1 when the schedule does not parse. A handler takes the job id as an i64.
cron-Schedule.salam
import mem
import time
import cron

mut hits := null as i64*

func tick(id: i64):
    atomic_add(hits, 1 as i64)
    println "job", id, cron.Name(id as int)
end

func main:
    hits = mem.AllocateZeroed(8 as u64) as i64*
    cron.SetZone("Invalid/Nowhere", 210)

    every := cron.Add("every minute", "* * * * *", tick)
    nightly := cron.Add("nightly", "0 3 * * *", tick)
    broken := cron.Add("broken", "nope", tick)
    println cron.Count(), cron.Name(every), cron.Name(nightly), broken
    println cron.IsBusy(every)

    started := cron.RunDue(1792800300 as i64)
    mut waited := 0
    until atomic_load(hits) == (0 as i64) or waited >= 200:
        time.Sleep(10)
        waited += 1
    end
    println "started", started, "hits", atomic_load(hits)
    println cron.IsBusy(every)

    cron.RunBackground()
    cron.Stop()
end

Count

func Count(): int
The number of registered jobs.

The example for this item is shown with the ones above: cron-Schedule.salam

Name

func Name(id: int): str
The name of a job.

The example for this item is shown with the ones above: cron-Schedule.salam

IsBusy

func IsBusy(id: int): bool
True while the job is running from a firing; the scheduler skips a busy job.

The example for this item is shown with the ones above: cron-Schedule.salam

RunDue

func RunDue(now: i64): int
Starts every job due at the given UTC time, each on its own thread, and returns how many started. A busy job is skipped.

The example for this item is shown with the ones above: cron-Schedule.salam

Run

func Run()
Blocks and fires due jobs once a minute until Stop is called.

The example for this item is shown with the ones above: cron-Schedule.salam

RunBackground

func RunBackground()
Starts the same loop on its own thread and returns at once.

The example for this item is shown with the ones above: cron-Schedule.salam

Stop

func Stop()
Asks the scheduler loop to end after the current job. A job already running is not interrupted.

The example for this item is shown with the ones above: cron-Schedule.salam