Erlcron
Erlcron is a cron-like job scheduler for Erlang applications. It is designed to be testable: the system clock can be arbitrarily set and fast-forwarded, making it easy to verify time-based behaviour in automated tests.
- Erlang-native syntax — job schedules are expressed as Erlang terms, or as standard Unix cron expression strings.
- Millisecond resolution — unlike Unix cron's one-minute polling interval, each job sleeps for the exact duration until its next execution.
- Per-job processes — every scheduled job runs in its own lightweight Erlang process.
- Daylight Saving Time aware — schedule recalculation is capped at 30 minutes so DST transitions do not cause jobs to be skipped or duplicated.
- Cluster support — the reference clock can be synchronised across a set of Erlang nodes with a single call.
Note: No output is logged or mailed automatically. Any logging must be done inside the job function itself.
Table of Contents
- Installation
- Quick Start
- Schedule Syntax
- Callable Forms
- Job Options
- API Reference
- Application Configuration
- Testing with Time Manipulation
Installation
For Elixir, add the following to mix.exs dependencies:
{:erlcron, "~> 1.0"}
For Erlng, add erlcron to your rebar.config:
{deps, [
{erlcron, "~> 1.0"}
]}.Or with the latest stable version from Hex:
{deps, [erlcron]}.Then start the application before scheduling any jobs:
application:ensure_all_started(erlcron).Quick Start
%% Run a function once at 3:30 PM today
erlcron:cron({once, {3, 30, pm}}, fun() -> io:fwrite("Hello!~n") end).
erlcron:cron({once, {3, 30, pm}}, {io, fwrite, ["Hello!~n"]}).
%% Run a function every day at 3:30 PM
erlcron:daily({3, 30, pm}, fun() -> io:fwrite("Daily reminder~n") end).
%% Run a function once after 5 minutes (300 seconds)
erlcron:at(300, fun() -> io:fwrite("Five minutes later~n") end).
%% Use a standard Unix cron expression (every 5 minutes)
erlcron:cron("*/5 * * * *", fun() -> io:fwrite("Every 5 min~n") end).
%% Cancel a job
JobRef = erlcron:cron({daily, {noon}}, fun() -> ok end),
erlcron:cancel(JobRef).Schedule Syntax
Schedules can be expressed as {Type, ...} tuples or as standard
5-field Unix cron expression strings/binaries.
Unix cron expressions
Any string or binary in the standard 5-field cron format is accepted wherever a schedule is expected:
┌───────── minute (0–59)
│ ┌─────── hour (0–23)
│ │ ┌───── day-of-month (1–31)
│ │ │ ┌─── month (1–12, accepted but ignored)
│ │ │ │ ┌─ day-of-week (0–7, 0/7=Sun; or mon/tue/wed/thu/fri/sat/sun)
│ │ │ │ │
* * * * *Supported field syntax:
| Syntax | Meaning |
|---|---|
* | every value |
N | specific value |
*/N | every N steps |
N-M | inclusive range |
a,b,c | list of values |
Examples and their equivalent erlcron specs:
| Cron expression | Equivalent erlcron schedule |
|---|---|
"* * * * *" | {daily, {every, {1, min}}} |
"*/5 * * * *" | {daily, {every, {5, min}}} |
"0 * * * *" | {daily, {every, {1, hr}}} |
"0 */2 * * *" | {daily, {every, {2, hr}}} |
"30 9 * * *" | {daily, {9, 30, 0}} |
"0,30 9 * * *" | {daily, [{9,0,0}, {9,30,0}]} |
"0 9 * * 1" | {weekly, mon, {9, 0, 0}} |
"0 9 * * 1,3" | {weekly, [mon,wed], {9, 0, 0}} |
"0 9 1 * *" | {monthly, 1, {9, 0, 0}} |
"0 9 1,15 * *" | {monthly, [1,15], {9, 0, 0}} |
The expression is parsed at job-submission time by ecrn_util:from_cron/1.
Both string() and binary() inputs are accepted.
Expressions that specify both day-of-month and day-of-week simultaneously
are rejected.
%% Every 5 minutes
erlcron:cron("*/5 * * * *", fun() -> poll() end).
%% Every weekday at 09:30
erlcron:cron(my_job, {"30 9 * * 1-5", fun() -> standup() end}).
%% Binary form works too
erlcron:cron(~"0 18 * * fri", fun() -> wrap_up() end).Time Literals (Duration)
Times can be expressed in several equivalent forms:
| Form | Meaning |
|---|---|
{H, am|pm} |
Hour in 12-hour clock, e.g. {3, pm} = 15:00:00 |
{H, M, am|pm} |
Hour and minute, e.g. {3, 30, pm} = 15:30:00 |
{H, M, S} |
24-hour clock, e.g. {15, 30, 0} |
{H, M, S, Ms} |
Same as {H, M, S} but includes milliseconds |
once
Run a job exactly one time.
%% At a specific time of day
{once, {3, 30, pm}}
%% At an exact 24-hour time
{once, {12, 23, 32}}
%% After N seconds from now
{once, 3600}Period
Period is used wherever a repeating or multi-time schedule is needed
(daily, weekly, monthly). It can take any of the following forms:
| Form | Example | Meaning |
|---|---|---|
When | {3, 30, pm} | Run once per day at the given time |
[When, ...] | [{9, am}, {5, pm}] | Run at each listed time per day |
{every, Duration} | {every, {30, min}} | Repeat every Duration all day |
{every, Duration, {between, From, To}} | {every, {5, min}, {between, {9, am}, {5, pm}}} | Repeat every Duration within the time window |
Duration units: hr / h, min / m, sec / s.
daily
Run a job every day. The time can be a single time or a list of times.
It also accepts an every tuple to repeat within a time window.
%% Every day at 3:30 PM
{daily, {3, 30, pm}}
%% Every day at multiple times
{daily, [{1, 10, am}, {1, 07, 30, am}]}
%% Every 23 seconds between 3 PM and 3:30 PM, every day
{daily, {every, {23, sec}, {between, {3, pm}, {3, 30, pm}}}}weekly
Run a job on one or more specific days of the week. Day-of-week atoms are:
mon, tue, wed, thu, fri, sat, sun.
%% Every Thursday at 2 AM
{weekly, thu, {2, am}}
%% Every Tuesday and Wednesday at 2 AM
{weekly, [tue, wed], {2, am}}
%% Every Friday at 2 AM
{weekly, fri, {2, am}}monthly
Run a job on one or more days of the month. Day values of 0 and negative
integers are counted backwards from the last day of the month: 0 means the
last day, -1 means the second-to-last day, and so on.
%% On the 1st of every month at 2 AM
{monthly, 1, {2, am}}
%% On the 1st, 7th, and 14th at 2 AM
{monthly, [1, 7, 14], {2, am}}
%% On the last day of every month at 2 AM
{monthly, 0, {2, am}}
%% On the last two days of every month at 2 AM
{monthly, [0, -1], {2, am}}Callable Forms
The action to execute can be specified in any of the following forms:
%% Zero-arity fun
fun() -> io:fwrite("Hello!~n") end
%% Two-arity fun receiving the job reference and the scheduled run time
fun(JobRef, DateTime) -> io:format("Running ~p at ~p~n", [JobRef, DateTime]) end
%% MFA tuple: the function is called as M:F(Args...)
{io, fwrite, ["Hello!~n"]}
%% MF tuple (no args): the function is called as M:F() or M:F(JobRef, DateTime)
%% depending on which arity is exported
{my_module, my_function}Job Options
Jobs accept an optional job_opts() map to control behaviour:
| Key | Type | Description |
|---|---|---|
id | term() | A stable identifier passed to lifecycle callbacks. |
hostnames | [string() | binary()] | Restrict the job to run only on the listed hostnames. |
on_job_start | fun((JobRef) -> ignore | any()) | {Mod, Fun} |
Called before executing the job. Return ignore to skip this execution. |
on_job_end | fun((JobRef, Result) -> any()) | {Mod, Fun} |
Called after the job finishes. Result is {ok, Value} or {error, {Reason, Stack}}. |
Important: If
on_job_startoron_job_endraise an exception, the supervisor will restart the job process. Make sure these callbacks handle errors internally to avoid hitting the supervisor's restart intensity limit.
API Reference
Scheduling jobs
%% Schedule a job from a full job() spec
erlcron:cron(Job) -> JobRef | ignored | already_started | {error, Reason}
erlcron:cron(JobRef, Job) -> ...
erlcron:cron(JobRef, Job, Opts) -> ...
%% Schedule by passing Sched and Task as separate arguments
erlcron:cron(Sched, Task) -> JobRef | ... % auto-generated ref, no opts
erlcron:cron(Sched, Task, Opts) -> JobRef | ... % auto-generated ref, with opts
erlcron:cron(JobRef, Sched, Task, Opts) -> ... % explicit ref, with opts
%% Sched can be an erlcron tuple or a Unix cron expression string/binary:
%% erlcron:cron({daily, {9, am}}, fun() -> ok end)
%% erlcron:cron("30 9 * * 1-5", fun() -> standup() end)
%% erlcron:cron(my_job, <<"0 18 * * fri">>, fun() -> wrap_up() end, #{})
%% Convenience: run once
erlcron:at(When, Fun) -> JobRef | ...
erlcron:at(JobRef, When, Fun) -> ...
erlcron:at(JobRef, When, Fun, Opts) -> ...
%% Convenience: run daily
erlcron:daily(When, Fun) -> JobRef | ...
erlcron:daily(JobRef, When, Fun) -> ...
erlcron:daily(JobRef, When, Fun, Opts) -> ...
%% Convenience: run weekly
erlcron:weekly(DOW, When, Fun) -> JobRef | ...
erlcron:weekly(JobRef, DOW, When, Fun) -> ...
erlcron:weekly(JobRef, DOW, When, Fun, Opts) -> ...
%% Convenience: run monthly
erlcron:monthly(DOM, When, Fun) -> JobRef | ...
erlcron:monthly(JobRef, DOM, When, Fun) -> ...
erlcron:monthly(JobRef, DOM, When, Fun, Opts) -> ...Managing jobs
%% Cancel a running job (returns true if found, false otherwise)
erlcron:cancel(JobRef) -> boolean()
%% Get the job references of all running jobs
erlcron:get_all_jobs() -> [JobRef]
%% Validate a schedule spec without scheduling it
erlcron:validate(RunWhen) -> ok | {error, Reason}Time functions
%% Current erlcron datetime (universal) with millisecond offset
erlcron:datetime() -> {calendar:datetime(), Milliseconds}
%% Reference datetime used as the clock base
erlcron:ref_datetime() -> {calendar:datetime(), Milliseconds}
%% Current time as milliseconds since Unix epoch
erlcron:epoch() -> Milliseconds
%% Current time as seconds since Unix epoch
erlcron:epoch_seconds() -> SecondsTime manipulation (for testing)
%% Set the clock on the local node (local time)
erlcron:set_datetime(DateTime) -> ok
%% Set the clock on the local node with explicit timezone
erlcron:set_datetime(DateTime, local | universal) -> ok
%% Reset the clock to the real system time
erlcron:reset_datetime() -> ok
%% Synchronise the clock across all connected nodes
erlcron:multi_set_datetime(DateTime) -> {Replies, BadNodes}
%% Synchronise the clock across a specific list of nodes
erlcron:multi_set_datetime(Nodes, DateTime) -> {Replies, BadNodes}Named job references
Jobs can be given a named atom reference that can be used in other calls:
erlcron:cron(my_job, {{once, {3, pm}}, {io, fwrite, ["It's 3 PM~n"]}}).
%% Later:
erlcron:cancel(my_job).Application Configuration
Jobs can be pre-configured via the standard Erlang application environment so
they start automatically when the application starts. Because anonymous
functions cannot be stored in config files, only {M, F, A} callables are
supported here.
[
{erlcron, [
{crontab, [
%% Simple job
{{once, {3, 30, pm}}, {io, fwrite, ["Hello, world!~n"]}},
%% Job restricted to a specific host
{{once, {12, 23, 32}}, {io, fwrite, ["Hello, world!~n"]},
#{hostnames => ["somehost"]}},
%% Recurring daily job to execute.
%% Either `my_module:hello_world()` or
%% `my_module:hello_world(JobRef, NowTime)`, whichever one
%% is exported.
{{daily, {every, {23, sec}, {between, {3, pm}, {3, 30, pm}}}},
{io, fwrite, ["Hello, world!~n"]}},
%% Map-based job spec (id, schedule, and task are required)
#{id => test_job,
schedule => {daily, {1, 0, pm}},
task => {io, fwrite, ["Hello, world!~n"]}},
%% Map-based spec using a Unix cron expression as the schedule
#{id => cron_job,
schedule => "30 9 * * 1-5",
task => {io, fwrite, ["Good morning!~n"]}},
%% Map-based spec with additional options
#{id => another_job,
schedule => {daily, {1, 0, pm}},
task => {io, fwrite, ["Hello, world!~n"]},
hostnames => ["myhost"]}
]},
%% Default options applied to every job unless overridden per-job
{defaults, #{
%% Only run jobs on these hosts
hostnames => ["myhost"],
%% Called before each job execution; return `ignore` to skip
on_job_start => {some_module, on_start},
%% Called after each job execution with the result
on_job_end => {some_module, on_end}
}}
]}
].
All jobs listed in crontab are started when the erlcron application starts.
Per-job options override the corresponding defaults options.
Testing with Time Manipulation
One of erlcron's primary design goals is testability. You can advance the scheduler clock to any point in the future and all jobs whose scheduled time falls within the elapsed interval will be executed in order:
%% Schedule a job for tomorrow
erlcron:cron({{once, Tomorrow}, fun() -> do_something() end}),
%% Fast-forward to tomorrow — the job will execute
erlcron:set_datetime(Tomorrow).To restore the real system clock after a test:
erlcron:reset_datetime().