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

Support localized cronjob scheduling with ambiguous and gap DateTime instances #133

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
47 changes: 34 additions & 13 deletions lib/crontab/cron_expression.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ defmodule Crontab.CronExpression do
@type t :: %Crontab.CronExpression{
extended: boolean,
reboot: boolean,
on_ambiguity: [ambiguity_opt],
second: [value(second)],
minute: [value(minute)],
hour: [value(hour)],
Expand All @@ -17,6 +18,8 @@ defmodule Crontab.CronExpression do
year: [value(year)]
}

@type ambiguity_opt :: :earlier | :later

@type interval :: :second | :minute | :hour | :day | :month | :weekday | :year

@typedoc deprecated: "Use Crontab.CronExpression.min_max/1 instead"
Expand Down Expand Up @@ -89,9 +92,14 @@ defmodule Crontab.CronExpression do
+-------------- :second Second (range: 0-59)

The `:extended` attribute defines if the second is taken into account.
When using localized DateTime, the `:on_ambiguity` attribute defines
whether the scheduler should return the earlier or later time when
the next run DateTime is ambiguous. `:on_ambiguity` defaults to `[:later]`.
To run on both, set it as `[:earlier, :later]`.
"""
defstruct extended: false,
reboot: false,
on_ambiguity: [:later],
second: [:*],
minute: [:*],
hour: [:*],
Expand All @@ -108,6 +116,7 @@ defmodule Crontab.CronExpression do
iex> ~e[*]
%Crontab.CronExpression{
extended: false,
on_ambiguity: [:later],
second: [:*],
minute: [:*],
hour: [:*],
Expand All @@ -116,9 +125,10 @@ defmodule Crontab.CronExpression do
weekday: [:*],
year: [:*]}

iex> ~e[*]e
iex> ~e[*]ea
%Crontab.CronExpression{
extended: true,
on_ambiguity: [:earlier],
second: [:*],
minute: [:*],
hour: [:*],
Expand All @@ -127,9 +137,10 @@ defmodule Crontab.CronExpression do
weekday: [:*],
year: [:*]}

iex> ~e[1 2 3 4 5 6 7]e
iex> ~e[1 2 3 4 5 6 7]ale
%Crontab.CronExpression{
extended: true,
on_ambiguity: [:earlier, :later],
second: [1],
minute: [2],
hour: [3],
Expand All @@ -139,9 +150,17 @@ defmodule Crontab.CronExpression do
year: [7]}
"""
@spec sigil_e(binary, charlist) :: t
def sigil_e(cron_expression, options)
def sigil_e(cron_expression, [?e]), do: Parser.parse!(cron_expression, true)
def sigil_e(cron_expression, _options), do: Parser.parse!(cron_expression, false)
def sigil_e(cron_expression, options \\ [?l]) do
Parser.parse!(
cron_expression,
?e in options,
cond do
?a in options and ?l in options -> [:earlier, :later]
?a in options -> [:earlier]
true -> [:later]
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we should have the option to not run at all in cases of ambiguity. How would that be expressed?

end
)
end

@doc """
Convert `Crontab.CronExpression` struct to tuple List.
Expand Down Expand Up @@ -194,20 +213,22 @@ defmodule Crontab.CronExpression do
## Examples

iex> IO.inspect %Crontab.CronExpression{}
~e[* * * * * *]
~e[* * * * * *]l

iex> import Crontab.CronExpression
iex> IO.inspect %Crontab.CronExpression{extended: true}
~e[* * * * * * *]e
~e[* * * * * * *]le

iex> import Crontab.CronExpression
iex> IO.inspect %Crontab.CronExpression{extended: true, on_ambiguity: [:earlier, :later]}
~e[* * * * * * *]ale
"""
@spec inspect(CronExpression.t(), any) :: String.t()
def inspect(cron_expression = %CronExpression{extended: false}, _options) do
"~e[" <> Composer.compose(cron_expression) <> "]"
end

def inspect(cron_expression = %CronExpression{extended: true}, _options) do
"~e[" <> Composer.compose(cron_expression) <> "]e"
def inspect(cron_expression = %CronExpression{}, _options) do
earlier = if(:earlier in cron_expression.on_ambiguity, do: "a", else: "")
later = if(:later in cron_expression.on_ambiguity, do: "l", else: "")
extended = if(cron_expression.extended, do: "e", else: "")
"~e[" <> Composer.compose(cron_expression) <> "]#{earlier}#{later}#{extended}"
end
end
end
29 changes: 14 additions & 15 deletions lib/crontab/cron_expression/parser.ex
Original file line number Diff line number Diff line change
Expand Up @@ -72,32 +72,31 @@ defmodule Crontab.CronExpression.Parser do
iex> Crontab.CronExpression.Parser.parse "* * * * *"
{:ok,
%Crontab.CronExpression{day: [:*], hour: [:*], minute: [:*],
month: [:*], weekday: [:*], year: [:*]}}
month: [:*], weekday: [:*], year: [:*], on_ambiguity: [:later]}}

iex> Crontab.CronExpression.Parser.parse "* * * * *", true
{:ok,
%Crontab.CronExpression{extended: true, day: [:*], hour: [:*], minute: [:*],
month: [:*], weekday: [:*], year: [:*], second: [:*]}}
month: [:*], weekday: [:*], year: [:*], second: [:*], on_ambiguity: [:later]}}

iex> Crontab.CronExpression.Parser.parse "fooo"
{:error, "Can't parse fooo as minute."}

"""
@spec parse(binary, boolean) :: result
def parse(cron_expression, extended \\ false)
@spec parse(binary, boolean, [CronExpression.ambiguity_opt()]) :: result
def parse(cron_expression, extended \\ false, ambiguity_opts \\ [:later])
maennchen marked this conversation as resolved.
Show resolved Hide resolved

def parse("@" <> identifier, _) do
def parse("@" <> identifier, _, _) do
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we also need to pass the ambiguity options to specials. For example most of them run at midnight. There's no guarantee that a TZ change does not happen over midnight.

special(String.downcase(identifier))
end

def parse(cron_expression, true) do
interpret(String.split(cron_expression, " "), @extended_intervals, %CronExpression{
extended: true
})
end
def parse(cron_expression, is_extended, ambiguity_opts) do
format = if(is_extended, do: @extended_intervals, else: @intervals)

def parse(cron_expression, false) do
interpret(String.split(cron_expression, " "), @intervals, %CronExpression{})
interpret(String.split(cron_expression, " "), format, %CronExpression{
extended: is_extended,
on_ambiguity: ambiguity_opts
})
end

@doc """
Expand All @@ -117,9 +116,9 @@ defmodule Crontab.CronExpression.Parser do
** (RuntimeError) Can't parse fooo as minute.

"""
@spec parse!(binary, boolean) :: CronExpression.t()
def parse!(cron_expression, extended \\ false) do
case parse(cron_expression, extended) do
@spec parse!(binary, boolean, [CronExpression.ambiguity_opt()]) :: CronExpression.t()
def parse!(cron_expression, extended \\ false, ambiguity_opts \\ [:later]) do
case parse(cron_expression, extended, ambiguity_opts) do
{:ok, result} -> result
{:error, error} -> raise error
end
Expand Down
86 changes: 61 additions & 25 deletions lib/crontab/date_helper.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
defmodule Crontab.DateHelper do
@moduledoc false
alias Crontab.CronExpression, as: CronExpr

@typep ambiguity_opts :: [CronExpr.ambiguity_opt()]
@type unit :: :year | :month | :day | :hour | :minute | :second | :microsecond

@type date :: NaiveDateTime.t() | DateTime.t()
Expand Down Expand Up @@ -115,14 +117,14 @@ defmodule Crontab.DateHelper do
@spec next_weekday_to(date :: date) :: Calendar.day()
def next_weekday_to(date) do
weekday = Date.day_of_week(date)
next_day = add(date, 1, :day)
previous_day = add(date, -1, :day)
next_day = shift(date, 1, :day)
previous_day = shift(date, -1, :day)

cond do
weekday == 7 && next_day.month == date.month -> next_day.day
weekday == 7 -> add(date, -2, :day).day
weekday == 7 -> shift(date, -2, :day).day
weekday == 6 && previous_day.month == date.month -> previous_day.day
weekday == 6 -> add(date, 2, :day).day
weekday == 6 -> shift(date, 2, :day).day
true -> date.day
end
end
Expand All @@ -140,14 +142,14 @@ defmodule Crontab.DateHelper do

"""
@spec inc_year(date) :: date when date: date
def inc_year(date = %{month: 2, day: 29}), do: add(date, 365, :day)
def inc_year(date = %{month: 2, day: 29}), do: shift(date, 365, :day)

def inc_year(date = %{month: month}) do
candidate = add(date, 365, :day)
candidate = shift(date, 365, :day)
date_leap_year_before_mar? = Date.leap_year?(date) and month < 3
candidate_leap_year_after_feb? = Date.leap_year?(candidate) and month > 2
adjustment = if candidate_leap_year_after_feb? or date_leap_year_before_mar?, do: 1, else: 0
add(candidate, adjustment, :day)
shift(candidate, adjustment, :day)
end

@doc """
Expand All @@ -163,14 +165,14 @@ defmodule Crontab.DateHelper do

"""
@spec dec_year(date) :: date when date: date
def dec_year(date = %{month: 2, day: 29}), do: add(date, -366, :day)
def dec_year(date = %{month: 2, day: 29}), do: shift(date, -366, :day)

def dec_year(date = %{month: month}) do
candidate = add(date, -365, :day)
candidate = shift(date, -365, :day)
date_leap_year_after_mar? = Date.leap_year?(date) and month > 2
candidate_leap_year_before_feb? = Date.leap_year?(candidate) and month < 3
adjustment = if date_leap_year_after_mar? or candidate_leap_year_before_feb?, do: -1, else: 0
add(candidate, adjustment, :day)
shift(candidate, adjustment, :day)
end

@doc """
Expand All @@ -191,7 +193,7 @@ defmodule Crontab.DateHelper do
Date.new!(year, month, day)
|> Date.days_in_month()

add(date, days + 1 - day, :day)
shift(date, days + 1 - day, :day)
end

@doc """
Expand All @@ -212,7 +214,7 @@ defmodule Crontab.DateHelper do
@spec dec_month(date) :: date when date: date
def dec_month(date = %{year: year, month: month, day: day}) do
days_in_last_month = Date.new!(year, month, 1) |> Date.add(-1) |> Date.days_in_month()
add(date, -(day + max(days_in_last_month - day, 0)), :day)
shift(date, -(day + max(days_in_last_month - day, 0)), :day)
end

@spec _beginning_of(date, [{unit, {any, any}}]) :: date when date: date
Expand Down Expand Up @@ -269,15 +271,15 @@ defmodule Crontab.DateHelper do

if modifier == 0,
do: date.day,
else: find_nth_weekday(add(date, 1, :day), month, weekday, modifier)
else: find_nth_weekday(shift(date, 1, :day), month, weekday, modifier)
end

defp find_nth_weekday(_, _, _, _), do: nil

@spec last_weekday_of_month(date :: date(), position :: :end) :: Calendar.day()
defp last_weekday_of_month(date = %{day: day}, :end) do
if Date.day_of_week(date) > 5 do
last_weekday_of_month(add(date, -1, :day), :end)
last_weekday_of_month(shift(date, -1, :day), :end)
else
day
end
Expand All @@ -289,25 +291,59 @@ defmodule Crontab.DateHelper do
if Date.day_of_week(date) == weekday do
day
else
last_weekday(add(date, -1, :day), weekday, :end)
last_weekday(shift(date, -1, :day), weekday, :end)
end
end

@doc false
def add(datetime = %NaiveDateTime{}, amt, unit), do: NaiveDateTime.add(datetime, amt, unit)
@spec shift(date, integer, unit, ambiguity_opts) :: date
def shift(datetime, amt, unit, ambiguity_opts \\ [:later])

def add(datetime = %DateTime{}, amt, unit) do
def shift(datetime = %NaiveDateTime{}, amt, unit, _), do: NaiveDateTime.add(datetime, amt, unit)

def shift(datetime = %DateTime{}, amt, unit, _) when unit in [:second, :minute] do
DateTime.add(datetime, amt, unit)
end

def shift(datetime = %DateTime{}, amt, unit, ambiguity_opts) do
candidate = DateTime.add(datetime, amt, unit)
adjustment = datetime.std_offset - candidate.std_offset
adjusted = DateTime.add(candidate, adjustment, :second)

if adjusted.std_offset != candidate.std_offset do
candidate
case DateTime.from_naive(DateTime.to_naive(candidate), candidate.time_zone) do
{:ambiguous, earlier, later} ->
resolve_ambiguity(datetime, earlier, later, amt, ambiguity_opts)

_ ->
resolve_potential_gap(datetime, candidate, amt, unit)
end
end

def resolve_ambiguity(from_time, earlier, later, amt, ambiguity_opts) do
if (:earlier in ambiguity_opts and from_time < earlier) or amt < 0 do
earlier
else
case DateTime.from_naive(DateTime.to_naive(adjusted), adjusted.time_zone) do
{:ambiguous, _, target} -> target
{:ok, target} -> target
end
later
end
end

def resolve_potential_gap(%{std_offset: n}, candidate = %{std_offset: n}, _, _), do: candidate

def resolve_potential_gap(_, candidate, _, unit) when unit in [:second, :minute, :hour] do
candidate
end

def resolve_potential_gap(%{std_offset: n}, candidate = %{std_offset: m}, amt, _) do
cond do
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this needs a bit more detail since DST shifts are not guaranteed to be one hour.

For example, Lord Howe Island is UTC +10:30 / +11:00

# move backwards from ST to DS, -1 to keep same hour
m > n and amt < 0 ->
shift(candidate, -1, :hour)

# move backwards from DS to ST, +1 to keep same hour
amt < 0 ->
shift(candidate, 1, :hour)

# move forward from ST to DS, -1 to keep same hour
true ->
shift(candidate, -1, :hour)
end
end
end
Loading