Packages

Cron parses cron expressions and calculates execution timings.

Current section

Files

Jump to
cron lib cron.ex
Raw

lib/cron.ex

defmodule Cron do
@moduledoc """
`Cron` parses cron expressions and calculates execution timings.
Expressions with 6 and 5 fields are expected. The variant with 6 fields adds a
field for seconds at the first position. The variant with 5 fields corresponds
to the standard Unix behavior. The field `second` will be set to `0` when
`Cron.new/1` gets 5 fields.
Possible values for the fields:
```
+----------- second (0 - 59)
| +--------- minute (0 - 59)
| | +------- hour (0 - 23)
| | | +----- day of the month (1 - 31)
| | | | +--- month (1 - 12)
| | | | | +- day of the week (0 - 6, SUN - SAT)
| | | | | |
* * * * * *
```
A field may contain an asterisk `*`, which means any of the possible values.
A range of values can be specified with an `-`.
Example: `10-15`.
A `/` can be used to setup steps. The value before the `/` specifies the start
value and the value behind the `/` specifies the step width.
Example: `*/2`, `4/2`.
Multiple values can be declared with a `,` separated list without any
whitespace.
Example: `1,5,10-15,*/2`.
The fields `month` and `day_of_week` are also accept names. Month accepts
`Jan`, `Feb`, `Mar`, `Apr`, `May`, `Jun`, `Jul`, `Aug`, `Sep`. `Oct`, `Nov`
and `Dez`. The case does not matter.
`day_of_week` accepts `Sun`, `Mon`, `Tue`, `Wed`, `Thu`, `Fri` and `Sat`. The
case does not matter. Keep in mind that `Sun` is equal to `0` and `Sat` is
equal to `6`.
All `Cron` functions are working with `NaiveDateTime` or `DateTime` that are
using the `Calendar.ISO`. Any `DateTime` must have the time zone `Etc/UTC`.
## Examples
A cron expression that triggers daily at 12:00:00
iex> "0 12 * * *"
...> |> Cron.new!()
...> |> Cron.next(~U[2021-12-06 22:11:44Z])
~U[2021-12-07 12:00:00Z]
`Cron.stream/2` calculates multiple values. The following cron expression
triggers ever 30 minutes from 12 to 14 at the first day in a month.
iex> "0 */30 12-14 1 * *"
...> |> Cron.new!()
...> |> Cron.stream(from: ~U[2021-12-06 11:22:33Z])
...> |> Enum.take(8)
[
~N[2022-01-01 12:00:00],
~N[2022-01-01 12:30:00],
~N[2022-01-01 13:00:00],
~N[2022-01-01 13:30:00],
~N[2022-01-01 14:00:00],
~N[2022-01-01 14:30:00],
~N[2022-02-01 12:00:00],
~N[2022-02-01 12:30:00]
]
"""
import Kernel, except: [match?: 2]
alias Cron.Calc
alias Cron.Parser
defstruct expression: "0 * * * * *",
second: 0,
minute: 0..59,
hour: 0..23,
day: 1..31,
month: 1..12,
day_of_week: 0..6
@type t :: %Cron{
expression: String.t(),
second: 0..59 | [0..59, ...] | Range.t(0..59, 0..59),
minute: 0..59 | [0..59, ...] | Range.t(0..59, 0..59),
hour: 0..23 | [0..23, ...] | Range.t(0..23, 0..23),
day: 1..31 | [1..31, ...] | Range.t(1..31, 1..31),
month: 1..12 | [1..13, ...] | Range.t(1..12, 1..12),
day_of_week: 0..6 | [0..6, ...] | Range.t(0..6, 0..6)
}
@type expression :: String.t()
@type reason :: atom() | [{atom(), String.t()}]
@type millisecond :: pos_integer()
@doc """
Returns an `:ok` tuple with a cron struct for the given expression string. If
the expression is invalid an `:error` will be returned.
Will accept expression with 6 (including `second`) and 5 (`second: 0`) fields.
## Examples
iex> {:ok, cron} = Cron.new("1 2 3 * *")
iex> cron
#Cron<1 2 3 * *>
iex> {:ok, cron} = Cron.new("0 1 2 3 * *")
iex> cron
#Cron<0 1 2 3 * *>
iex> Cron.new("66 1 2 3 * *")
{:error, second: "66"}
"""
@spec new(expression()) :: {:ok, Cron.t()} | :error | {:error, reason}
def new(string) do
with {:ok, data} <- Parser.run(string) do
{:ok, struct!(Cron, Keyword.put(data, :expression, string))}
end
end
@doc """
Same as `new/1`, but raises an `ArgumentError` exception in case of an invalid
expression.
"""
@spec new!(expression()) :: Cron.t()
def new!(string) do
case new(string) do
{:ok, cron} ->
cron
:error ->
raise ArgumentError, "invalid cron expression: #{inspect(string)}"
{:error, reason} ->
raise ArgumentError, "invalid cron expression: #{inspect(reason)}"
end
end
@doc """
Returns the next execution datetime.
If the given `datetime` matches `cron`, then also the following datetime is
returning. That means the resulting datetime is always greater than the given.
The function truncates the precision of the given `datetime` to seconds.
## Examples
iex> {:ok, cron} = Cron.new("0 0 0 * * *")
iex> Cron.next(cron, ~U[2022-01-01 12:00:00Z])
~U[2022-01-02 00:00:00Z]
iex> Cron.next(cron, ~U[2022-01-02 00:00:00Z])
~U[2022-01-03 00:00:00Z]
iex> Cron.next(cron, ~U[2022-01-02 00:00:00.999Z])
~U[2022-01-03 00:00:00Z]
"""
@spec next(Cron.t(), DateTime.t() | NaiveDateTime.t()) :: DateTime.t() | NaiveDateTime.t()
def next(cron, datetime \\ NaiveDateTime.utc_now())
def next(
%Cron{} = cron,
%DateTime{calendar: Calendar.ISO, time_zone: "Etc/UTC"} = datetime
) do
cron
|> next(DateTime.to_naive(datetime))
|> from_naive!()
end
def next(%Cron{} = cron, %NaiveDateTime{calendar: Calendar.ISO} = datetime) do
datetime
|> NaiveDateTime.truncate(:second)
|> Calc.next(cron)
end
@doc """
Returns an `:ok` tuple with the next execution datetime for which fun returns
a truthy value.
If no datetime can be found, an `:error` will be returned.
The function truncates the precision of the given `datetime` to seconds.
## Examples
iex> {:ok, cron} = Cron.new("0 0 29 2 *")
iex> Cron.next_while(cron, fn datetime -> Date.day_of_week(datetime) == 1 end)
{:ok, ~N[2044-02-29 00:00:00]}
iex> Cron.next_while(
...> cron, ~U[2044-02-29 00:00:00Z], fn datetime -> Date.day_of_week(datetime) == 1 end)
{:ok, ~U[2072-02-29 00:00:00Z]}
iex> {:ok, cron} = Cron.new("0 0 1 1 *")
iex> Cron.next_while(cron, fn _ -> false end)
:error
"""
@spec next_while(
Cron.t(),
DateTime.t() | NaiveDateTime.t(),
(NaiveDateTime.t() -> as_boolean(term))
) :: {:ok, DateTime.t() | NaiveDateTime.t()} | :error
def next_while(cron, datetime \\ NaiveDateTime.utc_now(), fun)
def next_while(%Cron{} = cron, %DateTime{} = datetime, fun) when is_function(fun, 1) do
cron
|> next_while(DateTime.to_naive(datetime), fun)
|> from_naive!()
end
def next_while(%Cron{} = cron, %NaiveDateTime{} = datetime, fun) when is_function(fun, 1) do
get_while(cron, datetime, fun, :asc)
end
@doc """
Same as `next_while/3`, but raises a `RuntimeError` exception in case no
execution datetime can be found.
"""
@spec next_while!(
Cron.t(),
DateTime.t() | NaiveDateTime.t(),
(NaiveDateTime.t() -> as_boolean(term))
) :: DateTime.t() | NaiveDateTime.t()
def next_while!(cron, datetime \\ NaiveDateTime.utc_now(), fun) do
case next_while(cron, datetime, fun) do
{:ok, next} -> next
:error -> raise "no follow up datetime found"
end
end
@doc """
Same as `next/3`, but returns the milliseconds until next execution datetime.
## Examples
iex> {:ok, cron} = Cron.new("0 0 0 * * *")
iex> Cron.until(cron, ~U[2022-01-01 12:00:00Z])
43200000
iex> Cron.until(cron, ~U[2022-01-02 00:00:00Z])
86400000
iex> Cron.until(cron, ~U[2022-01-02 00:00:00.999Z])
86399001
"""
@spec until(Cron.t(), DateTime.t() | NaiveDateTime.t()) :: millisecond
def until(%Cron{} = cron, datetime \\ DateTime.utc_now()) do
cron
|> next(datetime)
|> NaiveDateTime.diff(datetime, :millisecond)
end
@doc """
Same as `next_while/3`, but returns the milliseconds until next execution
datetime.
## Examples
iex> {:ok, cron} = Cron.new("0 0 29 2 *")
iex> Cron.until_while(
...> cron,
...> ~U[2022-01-01 00:00:00Z],
...> fn datetime -> Date.day_of_week(datetime) == 1 end)
{:ok, 699_321_600_000}
iex> Cron.until_while(
...> cron,
...> ~U[2044-02-28 23:59:59.100Z],
...> fn datetime -> Date.day_of_week(datetime) == 1 end)
{:ok, 900}
iex> {:ok, cron} = Cron.new("0 0 1 1 *")
iex> Cron.until_while(cron, fn _ -> false end)
:error
"""
@spec until_while(
Cron.t(),
DateTime.t() | NaiveDateTime.t(),
(NaiveDateTime -> as_boolean(term))
) :: {:ok, millisecond} | :error
def until_while(%Cron{} = cron, datetime \\ DateTime.utc_now(), fun) when is_function(fun, 1) do
with {:ok, next} <- next_while(cron, datetime, fun) do
{:ok, NaiveDateTime.diff(next, datetime, :millisecond)}
end
end
@doc """
Same as `until_while!/3`, but raises a `RuntimeError` exception in case no
execution datetime can be found.
"""
@spec until_while!(
Cron.t(),
DateTime.t() | NaiveDateTime.t(),
(NaiveDateTime -> as_boolean(term))
) :: millisecond
def until_while!(%Cron{} = cron, datetime \\ DateTime.utc_now(), fun)
when is_function(fun, 1) do
case until_while(cron, datetime, fun) do
{:ok, until} -> until
:error -> raise "no follow up datetime found"
end
end
@doc """
Returns the previous execution datetime.
If the given `datetime` matches `cron`, then also the previous datetime is
returning. That means the resulting datetime is always lower than the given.
The function truncates the precision of the given `datetime` to seconds.
## Examples
iex> {:ok, cron} = Cron.new("0 0 0 * * *")
iex> Cron.previous(cron, ~U[2022-01-01 12:00:00Z])
~U[2022-01-01 00:00:00Z]
iex> Cron.previous(cron, ~U[2022-01-01 00:00:00Z])
~U[2021-12-31 00:00:00Z]
iex> Cron.previous(cron, ~U[2022-01-01 00:00:00.999Z])
~U[2021-12-31 00:00:00Z]
"""
@spec previous(Cron.t(), DateTime.t() | NaiveDateTime.t()) :: DateTime.t() | NaiveDateTime.t()
def previous(cron, datetime \\ NaiveDateTime.utc_now())
def previous(
%Cron{} = cron,
%DateTime{calendar: Calendar.ISO, time_zone: "Etc/UTC"} = datetime
) do
cron
|> previous(DateTime.to_naive(datetime))
|> from_naive!()
end
def previous(%Cron{} = cron, %NaiveDateTime{calendar: Calendar.ISO} = datetime) do
datetime
|> NaiveDateTime.truncate(:second)
|> Calc.previous(cron)
end
@doc """
Returns an `:ok` tuple with the previous execution datetime for which fun
returns a truthy value.
If no datetime can be found, an `:error` will be returned.
The function truncates the precision of the given `datetime` to seconds.
## Examples
iex> {:ok, cron} = Cron.new("0 0 29 2 *")
iex> Cron.previous_while(cron, fn datetime -> Date.day_of_week(datetime) == 1 end)
{:ok, ~N[2016-02-29 00:00:00]}
iex> Cron.previous_while(
...> cron, ~U[2016-02-29 00:00:00Z], fn datetime -> Date.day_of_week(datetime) == 1 end)
{:ok, ~U[1988-02-29 00:00:00Z]}
iex> {:ok, cron} = Cron.new("0 0 1 1 *")
iex> Cron.previous_while(cron, fn _ -> false end)
:error
"""
@spec previous_while(
Cron.t(),
DateTime.t() | NaiveDateTime.t(),
(NaiveDateTime.t() -> as_boolean(term))
) :: {:ok, DateTime.t() | NaiveDateTime.t()} | :error
def previous_while(cron, datetime \\ NaiveDateTime.utc_now(), fun)
def previous_while(%Cron{} = cron, %DateTime{} = datetime, fun) when is_function(fun, 1) do
cron
|> previous_while(DateTime.to_naive(datetime), fun)
|> from_naive!()
end
def previous_while(%Cron{} = cron, %NaiveDateTime{} = datetime, fun) when is_function(fun, 1) do
get_while(cron, datetime, fun, :desc)
end
@doc """
Same as `previous_while/3`, but raises a `RuntimeError` exception in case no
execution datetime can be found.
"""
@spec previous_while!(
Cron.t(),
DateTime.t() | NaiveDateTime.t(),
(NaiveDateTime.t() -> as_boolean(term))
) :: DateTime.t() | NaiveDateTime.t()
def previous_while!(cron, datetime \\ NaiveDateTime.utc_now(), fun) when is_function(fun, 1) do
case previous_while(cron, datetime, fun) do
{:ok, previous} -> previous
:error -> raise "no previous datetime found"
end
end
@doc """
Same as `previous/3`, but returns the milliseconds since last execution
datetime.
## Examples
iex> {:ok, cron} = Cron.new("0 0 0 * * *")
iex> Cron.since(cron, ~U[2022-01-02 00:00:00Z])
86_400_000
iex> Cron.since(cron, ~U[2022-01-02 00:01:00Z])
60_000
iex> Cron.since(cron, ~U[2022-01-02 00:01:00.999Z])
60_999
iex> Cron.since(cron, ~U[2022-01-02 00:00:00.999Z])
86_400_999
"""
@spec since(Cron.t(), DateTime.t() | NaiveDateTime.t()) :: millisecond
def since(%Cron{} = cron, datetime \\ DateTime.utc_now()) do
NaiveDateTime.diff(datetime, previous(cron, datetime), :millisecond)
end
@doc """
Same as `previous_while/3`, but returns the milliseconds since last execution
datetime.
## Examples
iex> {:ok, cron} = Cron.new("0 0 29 2 *")
iex> Cron.since_while(
...> cron,
...> ~U[2022-01-01 00:00:00Z],
...> fn datetime -> Date.day_of_week(datetime) == 1 end)
{:ok, 184_291_200_000}
iex> Cron.since_while(
...> cron,
...> ~U[2016-02-29 00:00:01.999Z],
...> fn datetime -> Date.day_of_week(datetime) == 1 end)
{:ok, 1_999}
iex> {:ok, cron} = Cron.new("0 0 1 1 *")
iex> Cron.since_while(cron, fn _ -> false end)
:error
"""
@spec since_while(
Cron.t(),
DateTime.t() | NaiveDateTime.t(),
(NaiveDateTime -> as_boolean(term))
) :: {:ok, millisecond} | :error
def since_while(%Cron{} = cron, datetime \\ DateTime.utc_now(), fun) do
with {:ok, previous} <- previous_while(cron, datetime, fun) do
{:ok, NaiveDateTime.diff(datetime, previous, :millisecond)}
end
end
@doc """
Same as `since_while!/3`, but raises a `RuntimeError` exception in case no
execution datetime can be found.
"""
@spec since_while!(
Cron.t(),
DateTime.t() | NaiveDateTime.t(),
(NaiveDateTime -> as_boolean(term))
) :: millisecond
def since_while!(%Cron{} = cron, datetime \\ DateTime.utc_now(), fun) do
case since_while(cron, datetime, fun) do
{:ok, since} -> since
:error -> raise "no previous datetime found"
end
end
@doc """
Returns true if the given `datetime` matches the `cron`.
The function truncates the precision of the given `datetime` to seconds.
Examples
iex> cron = Cron.new!("0 * * * *")
iex> Cron.match?(cron, ~U[2021-11-13 06:41:39Z])
false
iex> Cron.match?(cron, ~U[2021-11-13 13:00:00Z])
true
iex> Cron.match?(cron, ~U[2021-11-13 13:00:00.999Z])
true
iex> "* * * * * *" |> Cron.new!() |> Cron.match?()
true
"""
@spec match?(Cron.t(), DateTime.t() | NaiveDateTime.t()) :: boolean()
def match?(cron, datetime \\ NaiveDateTime.utc_now())
def match?(
%Cron{} = cron,
%DateTime{calendar: Calendar.ISO, time_zone: "Etc/UTC"} = datetime
) do
match?(cron, DateTime.to_naive(datetime))
end
def match?(%Cron{} = cron, %NaiveDateTime{calendar: Calendar.ISO} = datetime) do
datetime
|> NaiveDateTime.truncate(:second)
|> Calc.match?(cron)
end
@doc """
Returns a `Stream` for the given `cron`.
The stream ends after the last execution datetime in the year 9000 or -9000.
Options:
* `:from` - the start datetime.
Defaults to `NaiveDateTime.utc_now("Etc/UTC")`.
* `:oder` - `:asc` or `:desc` to get execution datetimes before or after
start datetime. Defaults to `:asc`.
## Examples
iex> cron = Cron.new!("0 0 12 1 * *")
iex> stream = Cron.stream(cron, from: ~U[2022-06-05 00:00:00Z])
iex> Enum.take(stream, 3)
[
~N[2022-07-01 12:00:00],
~N[2022-08-01 12:00:00],
~N[2022-09-01 12:00:00],
]
iex> stream = Cron.stream(cron, from: ~U[2022-06-05 00:00:00Z], order: :desc)
iex> Enum.take(stream, 3)
[
~N[2022-06-01 12:00:00],
~N[2022-05-01 12:00:00],
~N[2022-04-01 12:00:00],
]
iex> stream = Cron.stream(cron, from: ~U[9000-10-05 00:00:00Z])
iex> Enum.take(stream, 3)
[
~N[9000-11-01 12:00:00],
~N[9000-12-01 12:00:00]
]
"""
@spec stream(Cron.t(), keyword()) :: Enumerable.t()
def stream(%Cron{} = cron, opts \\ []) do
with {:ok, from} <- fetch(opts, :from),
{:ok, order} <- fetch(opts, :order) do
Stream.unfold(stream(from, cron, order), fn
%{year: year} when year > 9000 or year < -9000 -> nil
acc -> {acc, stream(acc, cron, order)}
end)
else
{:error, [{key, value}]} ->
raise ArgumentError, "invalid value for #{inspect(key)}, got #{inspect(value)}"
end
end
defp stream(datetime, cron, :asc), do: Calc.next(datetime, cron)
defp stream(datetime, cron, :desc), do: Calc.previous(datetime, cron)
defp fetch(opts, :from) do
case Keyword.fetch(opts, :from) do
{:ok, %DateTime{} = datetime} ->
{:ok, datetime |> DateTime.to_naive() |> NaiveDateTime.truncate(:second)}
{:ok, %NaiveDateTime{} = datetime} ->
{:ok, NaiveDateTime.truncate(datetime, :second)}
:error ->
{:ok, NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)}
{:ok, invalid} ->
{:error, from: invalid}
end
end
defp fetch(opts, :order) do
case Keyword.fetch(opts, :order) do
{:ok, order} = result when order in [:asc, :desc] -> result
:error -> {:ok, :asc}
{:ok, invalid} -> {:error, order: invalid}
end
end
defp get_while(cron, datetime, fun, order) do
cron
|> stream(from: datetime, order: order)
|> Stream.filter(fun)
|> head()
end
defp head(%Stream{} = stream), do: stream |> Enum.take(1) |> head()
defp head([]), do: :error
defp head([item]), do: {:ok, item}
defp from_naive!({:ok, %NaiveDateTime{} = datetime}), do: {:ok, from_naive!(datetime)}
defp from_naive!(%NaiveDateTime{} = datetime), do: DateTime.from_naive!(datetime, "Etc/UTC")
defp from_naive!(:error), do: :error
defimpl Inspect do
@spec inspect(Cron.t(), Inspect.Opts.t()) :: String.t()
def inspect(cron, _opts), do: "#Cron<#{cron.expression}>"
end
defimpl String.Chars do
@spec to_string(Cron.t()) :: String.t()
def to_string(%Cron{} = cron), do: cron.expression
end
end