Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add next_at/2 and last_at/2 for cron time calculations #1239

Merged
merged 4 commits into from
Feb 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
241 changes: 221 additions & 20 deletions lib/oban/cron/expression.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ defmodule Oban.Cron.Expression do
@moduledoc false

@type t :: %__MODULE__{
input: String.t(),
minutes: MapSet.t(),
hours: MapSet.t(),
days: MapSet.t(),
Expand All @@ -10,7 +11,8 @@ defmodule Oban.Cron.Expression do
reboot?: boolean()
}

defstruct [:minutes, :hours, :days, :months, :weekdays, reboot?: false]
@derive {Inspect, only: [:input]}
defstruct [:input, :minutes, :hours, :days, :months, :weekdays, reboot?: false]

@dow_map %{
"SUN" => "0",
Expand All @@ -37,29 +39,227 @@ defmodule Oban.Cron.Expression do
"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
cron
|> Map.from_struct()
|> Enum.all?(&included?(&1, datetime))
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 included?({_, :*}, _datetime), do: true
defp included?({:minutes, set}, datetime), do: MapSet.member?(set, datetime.minute)
defp included?({:hours, set}, datetime), do: MapSet.member?(set, datetime.hour)
defp included?({:days, set}, datetime), do: MapSet.member?(set, datetime.day)
defp included?({:months, set}, datetime), do: MapSet.member?(set, datetime.month)
defp included?({:weekdays, set}, datetime), do: MapSet.member?(set, day_of_week(datetime))
defp included?({:reboot?, _}, _datetime), do: true
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
datetime
|> Date.day_of_week()
|> Integer.mod(7)
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()}
Expand All @@ -70,18 +270,19 @@ defmodule Oban.Cron.Expression do
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__{reboot?: true}}
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__{
minutes: parse_field(mip, 0..59),
hours: parse_field(hrp, 0..23),
days: parse_field(dap, 1..31),
months: mop |> trans_field(@mon_map) |> parse_field(1..12),
weekdays: wdp |> trans_field(@dow_map) |> parse_field(0..6)
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 ->
Expand Down
78 changes: 76 additions & 2 deletions test/oban/cron/expression_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,86 @@ defmodule Oban.Cron.ExpressionTest do
end
end

describe "last_at/2" do
defp last_at(expr, time) do
expr
|> Expr.parse!()
|> Expr.last_at(time)
end

test "returning the system start time for @reboot" do
time = DateTime.utc_now()
last = last_at("@reboot", time)

assert DateTime.after?(time, last)
end

test "calculating the last time a cron matched" do
assert ~U[2024-11-21 00:54:00Z] == last_at("* * * * *", ~U[2024-11-21 00:55:00Z])
assert ~U[2024-11-21 00:54:00Z] == last_at("*/2 * * * *", ~U[2024-11-21 00:55:00Z])
assert ~U[2024-11-21 00:05:00Z] == last_at("5 * * * *", ~U[2024-11-21 00:06:00Z])
assert ~U[2024-11-21 01:00:00Z] == last_at("0 1 * * *", ~U[2024-11-21 01:02:00Z])
assert ~U[2024-11-21 02:00:00Z] == last_at("0 */2 * * *", ~U[2024-11-21 03:02:00Z])
assert ~U[2024-11-01 00:00:00Z] == last_at("0 0 1 * *", ~U[2024-11-21 01:01:00Z])
assert ~U[2024-09-05 02:00:00Z] == last_at("0 0-2 5 9 *", ~U[2024-11-21 01:01:00Z])
assert ~U[2023-09-05 02:00:00Z] == last_at("0 0-2 5 9 *", ~U[2024-09-04 00:00:00Z])
assert ~U[2022-09-05 01:00:00Z] == last_at("0 1 5 9 MON", ~U[2024-11-21 00:00:00Z])
end

property "the last_at time is always in the past" do
check all minutes <- minutes(),
hours <- hours(),
days <- days(),
months <- months() do
assert [minutes, hours, days, months, "*"]
|> Enum.join(" ")
|> last_at("Etc/UTC")
|> DateTime.before?(DateTime.utc_now())
end
end
end

describe "next_at/2" do
defp next_at(expr, time) do
expr
|> Expr.parse!()
|> Expr.next_at(time)
end

test "the next run for @reboot is unknown" do
assert :unknown == next_at("@reboot", "Etc/UTC")
end

test "calculating the next time a cron will match" do
assert ~U[2024-11-21 00:56:00Z] == next_at("* * * * *", ~U[2024-11-21 00:55:00Z])
assert ~U[2024-11-21 00:56:00Z] == next_at("*/2 * * * *", ~U[2024-11-21 00:55:00Z])
assert ~U[2024-11-21 01:05:00Z] == next_at("5 * * * *", ~U[2024-11-21 00:06:00Z])
assert ~U[2024-11-21 13:00:00Z] == next_at("0 13 * * *", ~U[2024-11-21 01:02:00Z])
assert ~U[2024-11-21 04:00:00Z] == next_at("0 */2 * * *", ~U[2024-11-21 03:02:00Z])
assert ~U[2024-12-01 00:00:00Z] == next_at("0 0 1 * *", ~U[2024-11-21 01:01:00Z])
assert ~U[2025-09-05 00:00:00Z] == next_at("0 0-2 5 9 *", ~U[2024-11-21 01:01:00Z])
assert ~U[2024-09-05 00:00:00Z] == next_at("0 0-2 5 9 *", ~U[2024-09-04 00:00:00Z])
assert ~U[2027-09-05 01:00:00Z] == next_at("0 1 5 9 SUN", ~U[2024-11-21 00:00:00Z])
end

property "the next_at time is always in the future" do
check all minutes <- minutes(),
hours <- hours(),
days <- days(),
months <- months() do
assert [minutes, hours, days, months, "*"]
|> Enum.join(" ")
|> next_at("Etc/UTC")
|> DateTime.after?(DateTime.utc_now())
end
end
end

defp minutes, do: expression(0..59)

defp hours, do: expression(0..23)

defp days, do: expression(1..31)
defp days, do: expression(1..28)

defp months do
one_of([
Expand Down Expand Up @@ -141,7 +216,6 @@ defmodule Oban.Cron.ExpressionTest do
map(integer(min..max), &"#{&1}/2"),
map(integer((min + 1)..max), &"*/#{&1}"),
map(integer(min..(max - 2)), &"#{&1}-#{&1 + 1}"),
map(integer(min..(max - 3)), &"#{&1}-#{&1 + 2}/1"),
map(integer(min..(max - 3)), &"#{&1}-#{&1 + 2}/2"),
list_of(integer(min..max), length: 1..10)
]) do
Expand Down
Loading