Current section

Files

Jump to
oban lib oban cron expression.ex
Raw

lib/oban/cron/expression.ex

defmodule Oban.Cron.Expression do
@moduledoc false
@type t :: %__MODULE__{
input: String.t(),
minutes: MapSet.t(),
hours: MapSet.t(),
days: MapSet.t(),
months: MapSet.t(),
weekdays: MapSet.t(),
reboot?: boolean()
}
@derive {Inspect, only: [:input]}
defstruct [:input, :minutes, :hours, :days, :months, :weekdays, reboot?: false]
@dow_map %{
"SUN" => "0",
"MON" => "1",
"TUE" => "2",
"WED" => "3",
"THU" => "4",
"FRI" => "5",
"SAT" => "6"
}
@mon_map %{
"JAN" => "1",
"FEB" => "2",
"MAR" => "3",
"APR" => "4",
"MAY" => "5",
"JUN" => "6",
"JUL" => "7",
"AUG" => "8",
"SEP" => "9",
"OCT" => "10",
"NOV" => "11",
"DEC" => "12"
}
@min_range 0..59
@hrs_range 0..23
@day_range 1..31
@mon_range 1..12
@dow_range 0..6
@doc """
Check whether a cron expression matches the current date and time.
## Example
Check against the default `utc_now`:
iex> "* * * * *" |> parse!() |> now?()
true
Check against a provided date time:
iex> "0 1 * * *" |> parse!() |> now?(~U[2025-01-01 01:00:00Z])
true
iex> "0 1 * * *" |> parse!() |> now?(~U[2025-01-01 02:00:00Z])
false
Check if it is time to reboot:
iex> "@reboot" |> parse!() |> now?()
true
"""
@spec now?(cron :: t(), datetime :: DateTime.t()) :: boolean()
def now?(cron, datetime \\ DateTime.utc_now())
def now?(%__MODULE__{reboot?: true}, _datetime), do: true
def now?(%__MODULE__{} = cron, datetime) do
dow = day_of_week(datetime)
MapSet.member?(cron.months, datetime.month) and
MapSet.member?(cron.weekdays, dow) and
MapSet.member?(cron.days, datetime.day) and
MapSet.member?(cron.hours, datetime.hour) and
MapSet.member?(cron.minutes, datetime.minute)
end
@doc """
Returns the next DateTime that matches the cron expression.
When given a DateTime, it finds the next matching time after that DateTime. When given
a timezone string, it finds the next matching time in that timezone.
## Examples
Find the next matching time after a given DateTime:
iex> "0 1 * * *" |> parse!() |> next_at(~U[2025-01-01 00:00:00Z])
~U[2025-01-01 01:00:00Z]
Find the next matching time in a specific timezone:
iex> "0 1 * * *" |> parse!() |> next_at("America/New_York")
~U[2025-01-02 01:00:00-05:00]
"""
@spec next_at(t(), DateTime.t() | Calendar.time_zone()) :: :unknown | DateTime.t()
def next_at(expr, timezone \\ "Etc/UTC")
def next_at(%{reboot?: true}, _timezone_or_datetime), do: :unknown
def next_at(expr, timezone) when is_binary(timezone) do
next_at(expr, DateTime.now!(timezone))
end
def next_at(expr, time) when is_struct(time, DateTime) do
time =
time
|> DateTime.add(1, :minute)
|> DateTime.truncate(:second)
|> Map.put(:second, 0)
match_at(expr, time, :next)
end
defp match_at(expr, time, dir) do
cond do
now?(expr, time) ->
time
not MapSet.member?(expr.months, time.month) ->
match_at(expr, bump_month(expr, time, dir), dir)
not MapSet.member?(expr.days, time.day) ->
match_at(expr, bump_day(expr, time, dir), dir)
not MapSet.member?(expr.hours, time.hour) ->
match_at(expr, bump_hour(expr, time, dir), dir)
true ->
match_at(expr, bump_minute(expr, time, dir), dir)
end
end
defp bump_year(_expr, time, :next) do
%{time | month: 0, year: time.year + 1}
end
defp bump_year(_expr, time, :last) do
%{time | month: 13, year: time.year - 1}
end
defp bump_month(expr, time, dir) do
day = if dir == :next, do: 0, else: 32
case find_best(expr.months, time.month, dir) do
nil -> bump_year(expr, time, dir)
month -> %{time | day: day, month: month}
end
end
defp bump_day(expr, time, dir) do
hour = if dir == :next, do: -1, else: 24
days = days_in_month(time)
matches_weekday? = fn day ->
day <= days and
%{time | day: day}
|> day_of_week()
|> then(&(&1 in expr.weekdays))
end
expr.days
|> Enum.filter(matches_weekday?)
|> find_best(time.day, dir)
|> case do
nil -> bump_month(expr, time, dir)
day -> %{time | day: day, hour: hour}
end
end
defp bump_hour(expr, time, dir) do
minute = if dir == :next, do: -1, else: 60
case find_best(expr.hours, time.hour, dir) do
nil -> bump_day(expr, time, dir)
hour -> %{time | hour: hour, minute: minute}
end
end
defp bump_minute(expr, time, dir) do
case find_best(expr.minutes, time.minute, dir) do
nil -> bump_hour(expr, time, dir)
minute -> %{time | minute: minute}
end
end
defp find_best(set, value, :next) do
set
|> Enum.sort()
|> Enum.find(&(&1 > value))
end
defp find_best(set, value, :last) do
set
|> Enum.sort(:desc)
|> Enum.find(&(&1 < value))
end
@doc """
Returns the most recent DateTime that matches the cron expression.
When given a DateTime, it finds the last matching time before that DateTime. When given
a timezone string, it finds the last matching time in that timezone.
## Examples
Find the last matching time before a given DateTime:
iex> "0 1 * * *" |> parse!() |> last_at(~U[2025-01-01 01:00:00Z])
~U[2025-01-01 00:01:00Z]
Find the last matching time in a specific timezone:
iex> "0 1 * * *" |> parse!() |> last_at("America/New_York")
~U[2025-01-01 05:01:00-05:00]
"""
@spec last_at(t(), DateTime.t() | Calendar.time_zone()) :: DateTime.t()
def last_at(expr, timezone \\ "Etc/UTC")
def last_at(%{reboot?: true}, _timezone_or_datetime) do
{uptime, _} = :erlang.statistics(:wall_clock)
DateTime.utc_now()
|> DateTime.add(-uptime, :millisecond)
|> DateTime.truncate(:second)
|> Map.put(:second, 0)
end
def last_at(expr, timezone) when is_binary(timezone) do
last_at(expr, DateTime.now!(timezone))
end
def last_at(expr, time) when is_struct(time, DateTime) do
time =
time
|> DateTime.add(-1, :minute)
|> DateTime.truncate(:second)
|> Map.put(:second, 0)
match_at(expr, time, :last)
end
defp day_of_week(datetime) do
if Calendar.ISO.valid_date?(datetime.year, datetime.month, datetime.day) do
datetime
|> Date.day_of_week()
|> Integer.mod(7)
else
-1
end
end
defp days_in_month(datetime) do
Calendar.ISO.days_in_month(datetime.year, datetime.month)
end
@spec parse(input :: binary()) :: {:ok, t()} | {:error, Exception.t()}
def parse("@annually"), do: parse("0 0 1 1 *")
def parse("@yearly"), do: parse("0 0 1 1 *")
def parse("@monthly"), do: parse("0 0 1 * *")
def parse("@weekly"), do: parse("0 0 * * 0")
def parse("@midnight"), do: parse("0 0 * * *")
def parse("@daily"), do: parse("0 0 * * *")
def parse("@hourly"), do: parse("0 * * * *")
def parse("@reboot"), do: {:ok, %__MODULE__{input: "@reboot", reboot?: true}}
def parse(input) when is_binary(input) do
case String.split(input, ~r/\s+/, trim: true, parts: 5) do
[mip, hrp, dap, mop, wdp] ->
{:ok,
%__MODULE__{
input: input,
minutes: parse_field(mip, @min_range),
hours: parse_field(hrp, @hrs_range),
days: parse_field(dap, @day_range),
months: mop |> trans_field(@mon_map) |> parse_field(@mon_range),
weekdays: wdp |> trans_field(@dow_map) |> parse_field(@dow_range)
}}
_parts ->
throw({:error, "incorrect number of fields in expression: #{input}"})
end
catch
{:error, message} -> {:error, %ArgumentError{message: message}}
end
@spec parse!(input :: binary()) :: t()
def parse!(input) when is_binary(input) do
case parse(input) do
{:ok, cron} -> cron
{:error, exception} -> raise(exception)
end
end
defp parse_field(field, range) do
range_set = MapSet.new(range)
parsed =
field
|> String.split(~r/\s*,\s*/)
|> Enum.flat_map(&parse_part(&1, range))
|> MapSet.new()
if not MapSet.subset?(parsed, range_set) do
throw({:error, "expression field #{field} is out of range: #{inspect(range)}"})
end
parsed
end
defp trans_field(field, map) do
Enum.reduce(map, field, fn {val, rep}, acc -> String.replace(acc, val, rep) end)
end
defp parse_part(part, range) do
cond do
part == "*" ->
range
part =~ ~r/^\d+$/ ->
parse_literal(part)
part =~ ~r/^\*\/[1-9]\d?$/ ->
parse_step(part, range)
part =~ ~r/^\d+(\-\d+)?\/[1-9]\d?$/ ->
parse_range_step(part, range)
part =~ ~r/^\d+\-\d+$/ ->
parse_range(part, range)
true ->
throw({:error, "unrecognized cron expression: #{part}"})
end
end
defp parse_literal(part) do
part
|> String.to_integer()
|> List.wrap()
end
defp parse_step(part, range) do
step =
part
|> String.replace_leading("*/", "")
|> String.to_integer()
Enum.take_every(range, step)
end
defp parse_range_step(part, max_range) do
[range, step] = String.split(part, "/")
parse_step(step, parse_range(range, max_range))
end
defp parse_range(part, max_range) do
case String.split(part, "-") do
[rall] ->
String.to_integer(rall)..Enum.max(max_range)
[rmin, rmax] ->
rmin = String.to_integer(rmin)
rmax = String.to_integer(rmax)
if rmin <= rmax do
rmin..rmax
else
throw(
{:error,
"left side (#{rmin}) of a range must be less than or equal to the right side (#{rmax})"}
)
end
end
end
end