This is my cron (micron); there are others like it but this one is mine. A cron-scheduler library in Go
micron is a Go library that allows adding cron-like scheduler(s) to Go apps, compatible with
Unix's cron time/date strings, to execute actions within the context of the app.
By itself, cron is a fantastic tool released in the mid-70's, written in C, where the user defines a specification in
a crontab, a file listing jobs with a reference of a time/date specification and a (Unix) command to execute.
Within Go, it provides the same set of features as the original binary, served in a library as a
(blocking / background) pluggable service. This means full compatibility with cron strings for scheduling, support for
multiple executable types or functions, and support for multiple job scheduling (with different time/date
specifications). Additionally, the library extends this functionality to support definition of seconds' frequency in
cron-strings, @reboot, @midnight, and @every <duration> descriptors, ? wildcards, a-b/step range-with-step
expressions, runner middlewares (panic recovery, overlap protection, jitter, and retry), iter.Seq[time.Time] schedule
iterators, context support, error returns, and full observability coverage (metrics, logs, and traces).
In a work environment, we see cron many times, in different scenarios. From cron installations in bare-metal Linux servers to one-shot containers configured in Kubernetes deployments. Some use-cases are very simple, others complex, but the point is that it is a tool used for nearly 50 years at the time of writing.
But the original tool is a C binary that executes a Unix command. If we want to explore schedulers for Go applications (e.g. a script executed every # hours), this means that the app needs to be compiled as a binary, and then to configure a cron-job to execute the app in a command.
While this is fine, it raises the question -- what if I want to include it within the application? This should make a lot of sense to you if you're a fan of SQLite like me.
There were already two libraries with different implementations, in Go:
robfig/cronwith 12k GitHub starsgo-co-op/gocronwith 4.3k GitHub stars
Don't get me wrong -- there is nothing inherently wrong about these implementations; I went through them carefully both
for insight and to understand what could I explore differently. A very obvious change would be a more "modern"
approach including a newer Go version (go1.27.1); which by itself includes log/slog, iter.Seq, sync.WaitGroup.Go,
and all the other observability-related decorators that also leverage context.Context.
Another more obvious exploration path would be the parser logic, as I could use my generic lexer and generic parser in order to potentially improve it.
Lastly I could try to split the cron service's components to be more configurable even in future iterations, once I had decided on the general API for the library. There was enough ground to explore and to give it a go. :)
A personal project that I have for a Steam CLI app is currently using this cron library to regularly check for discounts in the Steam Store, for certain products on a certain frequency, as configured by the user.
Using micron is as layered and modular as you want it to be. This chapter describes how to use the library effectively.
You're able to fetch micron as a Go module by importing it in your project and running go mod tidy:
package main
import (
"context"
"fmt"
"github.com/zalgonoise/micron/v3"
"github.com/zalgonoise/micron/v3/executor"
)
func main() {
fn := func(context.Context) error {
fmt.Println("done!")
return nil
}
c, err := micron.New(
micron.WithJob("my-job", "* * * * *", fn, executor.WithRecover()),
)
if err != nil {
panic(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go c.Run(ctx)
// ...
}The runtime is the component that controls how the module runs -- that is, controlling the flow of job selection and
execution. The runtime allows cron to be executed as a goroutine, as its Runtime.Run method has no
returns, and errors are channeled via its Runtime.Err method (which returns a receive-only error channel).
The lifetime of the cron runtime is managed with a context.Context provided when calling Runtime.Run --
which can impose a cancellation or timeout strategy.
Creating a cron runtime starts with the micron.New constructor function:
func New(options ...cfg.Option[*Runtime]) (*Runtime, error)This function accepts variadic cfg.Option[*Runtime] options, allowing full modularity in how you build your cron
runtime, provided that it supplies either:
- a
SelectorviaWithSelector, - a set of
selector.ExecutorviaWithExecutors, or - one or more jobs via
WithJob.
Below is a table with all the options available for creating a cron runtime:
| Function | Input Parameters | Description |
|---|---|---|
WithSelector |
sel Selector |
Configures the Runtime with the input Selector. |
WithExecutors |
execs ...selector.Executor |
Configures the Runtime with pre-built executors, constructing a selector automatically if WithSelector is not set. |
WithJob |
id, cron string, fn func(context.Context) error, execOpts ...cfg.Option[*executor.Executable] |
Registers a scheduled job directly on the Runtime with optional executor.Executable options. |
WithBlockingSelector |
(none) | Configures the Runtime to construct a selector.BlockingSelector when using WithExecutors or WithJob. |
WithLocation |
loc *time.Location |
Sets the default time.Location used by jobs registered via WithJob. |
WithErrorBufferSize |
size int |
Defines the capacity of the error channel exposed by Runtime.Err. |
WithMetrics |
m Metrics |
Configures the Runtime with the input metrics registry. |
WithLogger |
logger *slog.Logger |
Configures the Runtime with the input slog.Logger. |
WithLogHandler |
handler slog.Handler |
Configures the Runtime with logging using the input slog.Handler. |
WithTrace |
tracer trace.Tracer |
Configures the Runtime with the input OpenTelemetry trace.Tracer. |
This component is responsible for picking up the next job to execute, according to their schedule frequency. There are
two implementations in the selector package:
Selector(selector.New(ctx, options...)): An asynchronous, non-blocking selector that runs each configuredExecutorin its own background goroutine, aggregating any errors atomically for retrieval viaNextorError.BlockingSelector(selector.NewBlockingSelector(options...)): A synchronous selector that finds the nearest scheduled task(s) and blocks until they finish executing (running multiple same-time tasks concurrently viaexecutor.Multi).
func New(ctx context.Context, options ...cfg.Option[*Config]) (*Selector, error)
func NewBlockingSelector(options ...cfg.Option[*Config]) (*BlockingSelector, error)Below is a table with all the options available for creating a selector:
| Function | Input Parameters | Description |
|---|---|---|
WithExecutors |
executors ...Executor |
Configures the selector with the input Executor instances. |
WithTimeout |
dur time.Duration |
Configures the poll/wait timeout duration on a non-blocking Selector. |
WithClock |
clock Clock |
Configures a custom Clock implementation (defaults to time.Now). |
WithMetrics |
m Metrics |
Configures the selector with the input metrics collector. |
WithLogger |
logger *slog.Logger |
Configures the selector with the input slog.Logger. |
WithLogHandler |
handler slog.Handler |
Configures the selector with a slog.Logger built from the input slog.Handler. |
WithTrace |
tracer trace.Tracer |
Configures the selector with the input OpenTelemetry trace.Tracer. |
The Executable in package executor is the component that waits until the next
scheduled timestamp and executes the configured Runner instances.
It exposes:
Exec(ctx context.Context, now time.Time) error— waits until the next scheduled time and runs all configured runners.Next(ctx context.Context, now time.Time) time.Duration— returns the duration until the next scheduled execution.ID() string— returns the executor's identifier.Scheduler() Scheduler— returns the underlyingScheduler.
func New(id string, runners []Runner, options ...cfg.Option[*Executable]) (*Executable, error)Below is a table with all the options available for creating an Executable:
| Function | Input Parameters | Description |
|---|---|---|
WithRunners |
runners ...Runner |
Appends one or more Runner instances to the executor. |
WithRunnable |
fn func(context.Context) error |
Appends a function as a Runnable runner to the executor. |
WithScheduler |
sched Scheduler |
Configures the executor with a custom or pre-built Scheduler. |
WithSchedule |
cron string, loc *time.Location |
Parses cron (5/6-field cron, @reboot, @midnight, @every <duration>, etc.) and configures the scheduler. |
WithTimeout |
dur time.Duration |
Sets the per-execution context timeout applied when invoking runners (default 10m). |
WithRecover |
(none) | Wraps all runners with panic recovery (ErrRecoveredPanic). Standalone decorator: Recover. |
WithSkipIfRunning |
(none) | Skips overlapping concurrent executions of a runner if the previous run is still active. Standalone decorator: SkipIfRunning. |
WithJitter |
maxJitter time.Duration |
Adds a random delay in [0, maxJitter) before each runner execution. Standalone decorator: Jitter. |
WithRetry |
maxRetries int, backoff time.Duration |
Retries failed runner executions up to maxRetries times with backoff delay. Standalone decorator: Retry. |
WithMetrics |
m Metrics |
Configures the executor with the input metrics collector. |
WithLogger |
logger *slog.Logger |
Configures the executor with the input slog.Logger. |
WithLogHandler |
handler slog.Handler |
Configures the executor with a slog.Logger built from the input slog.Handler. |
WithTrace |
tracer trace.Tracer |
Configures the executor with the input OpenTelemetry trace.Tracer. |
The schedule package provides three Scheduler implementations:
CronSchedule(schedule.New(options...)): Standard and extended cron schedule localized to atime.Location.RebootSchedule(schedule.NewRebootSchedule(options...)): One-shot schedule (@reboot) that triggers immediately on its first call and returnstime.Time{}thereafter.IntervalSchedule(schedule.NewIntervalSchedule(interval, options...)): Fixed-duration interval schedule (@every <duration>).
Both CronSchedule and IntervalSchedule also expose:
Seq(ctx context.Context, from time.Time) iter.Seq[time.Time]— Go 1.23+ range-over-func iterator yielding upcoming execution times.NextN(ctx context.Context, from time.Time, n int) []time.Time— returns a slice of the nextnupcoming execution times.
func New(options ...cfg.Option[*CronSchedule]) (*CronSchedule, error)
func NewRebootSchedule(options ...cfg.Option[*RebootSchedule]) *RebootSchedule
func NewIntervalSchedule(interval time.Duration, options ...cfg.Option[*IntervalSchedule]) (*IntervalSchedule, error)Below is a table with the options available for creating a CronSchedule:
| Function | Input Parameters | Description |
|---|---|---|
WithSchedule |
cron *cronlex.Schedule |
Configures the CronSchedule with a parsed or built *cronlex.Schedule. |
WithCronString |
cron string |
Parses the cron expression string and configures the CronSchedule. |
WithLocation |
loc *time.Location |
Configures the CronSchedule with the input time.Location (defaults to time.UTC). |
WithObservability |
m Metrics, logger *slog.Logger, tracer trace.Tracer |
Configures metrics, logging, and tracing on the CronSchedule. |
Programmatic schedule construction is also available via schedule/builder, including
Every(), At(val), Between(from, to), RangeWithStep(from, to, step), and On(steps...).
Schedule is a data structure that holds a set of
Resolvers, for each node, segment or unit of the schedule. This implementation
focuses on the cron string specification with added support for a seconds definition (alongside minutes,
hours, days-of-the-month, months and weekdays):
type Schedule struct {
Sec Resolver
Min Resolver
Hour Resolver
DayMonth Resolver
Month Resolver
DayWeek Resolver
}The implementations of Resolver can be found in the
resolve package. These are derived from parsing a cron string and assigned
automatically when calling cronlex.Parse.
Supported cron syntax features include:
- Every-value sequences using
*(or?in day-of-month and day-of-week fields) - Fixed values (e.g.
15,JAN,MON,0or7for Sunday) - Inclusive range value sequences using
-(e.g.0-15,MON-FRI) - Step value sequences using
/(e.g.*/3,0/15) - Range-with-step sequences using
-and/(e.g.0-30/5) - Comma-separated lists combining values, ranges, and steps (e.g.
0-15/3,20,25) - Overrides:
@reboot,@hourly,@daily,@midnight,@weekly,@monthly,@yearly,@annually, and@every <duration>
There are four Resolver types in schedule/resolve:
Everytime: Always returns0(matches any value).FixedSchedule: Matches a single fixed valueAtwithinMax.RangeSchedule: Matches any value inclusively within[From, To].StepSchedule: Matches a sorted, deduplicated set of discreteSteps.
cronlex.Parse consumes a cron string and returns a *Schedule and an error:
func Parse(cron string) (*Schedule, error)
func ParseInterval(cron string) (time.Duration, error)This is a process broken down in three phases built on zalgonoise/lex and
zalgonoise/parse:
- A lexer (
StateFunc), that consumes bytes from the cron string and emitsTokenitems. - A parser (
ParseFunc), which consumes tokens and builds an abstract syntax tree. - A validator and processor (
ValidateandProcessFunc), which validates all nodes and bounds and constructs the*Schedule.
A working example is the Steam CLI app mentioned in the
Motivation section above. This application exposes some commands, one of them being
monitor. This file provides some
insight on how the cron service is set up from a main.go / script-like approach.
You can also take a look
at its runner.go file, that
implements the executor.Runner interface.
This is not a one-size-fits-all solution! Please take your time to evaluate it for your own needs with due diligence. While having a library for this and a library for that is pretty nice, it could potentially be only overhead hindering the true potential of your app! Be sure to read the code that you are using to be a better judge if it is a good fit for your project. With that in mind, I hope you enjoy this library. Feel free to contribute by filing either an issue or a pull request.