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.
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)
endTimes 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.
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)
endWeekdays 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.
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()
endRun 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.
Stopis 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 Exprok flag.| Name | Type | Description |
|---|---|---|
minutes | i64 = 0 | The minutes of the hour that match, as a bit mask. |
hours | i64 = 0 | The hours of the day that match, as a bit mask. |
days | i64 = 0 | The days of the month that match, as a bit mask. |
months | i64 = 0 | The months that match, as a bit mask. |
weekdays | i64 = 0 | The weekdays that match, as a bit mask; Sunday is bit 0. |
dom_star | bool = true | True when the day-of-month field was *. |
dow_star | bool = true | True when the day-of-week field was *. |
ok | bool = false | True when the text parsed; every other function refuses an expression whose ok is false. |
Functions
Parse
func Parse(spec: str): Expr@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.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)
endMatchFields
func MatchFields(e: Expr, minute: int, hour: int, day: int, month: int, weekday: int): boolimport 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)
endMatchesLocal
func MatchesLocal(e: Expr, local: i64): boolThe example for this item is shown with the ones above: cron-Match.salam
OffsetSeconds
func OffsetSeconds(zone: str, fallback_minutes: int, epoch: i64): i64The 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): i64after 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)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): inti64.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()
endCount
func Count(): intThe example for this item is shown with the ones above: cron-Schedule.salam
Name
func Name(id: int): strThe example for this item is shown with the ones above: cron-Schedule.salam
IsBusy
func IsBusy(id: int): boolThe example for this item is shown with the ones above: cron-Schedule.salam
RunDue
func RunDue(now: i64): intThe example for this item is shown with the ones above: cron-Schedule.salam
Run
func Run()Stop is called.The example for this item is shown with the ones above: cron-Schedule.salam
RunBackground
func RunBackground()The example for this item is shown with the ones above: cron-Schedule.salam
Stop
func Stop()The example for this item is shown with the ones above: cron-Schedule.salam