-
Notifications
You must be signed in to change notification settings - Fork 33
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
base: main
Are you sure you want to change the base?
Changes from 10 commits
f16ada0
36a50b9
44a1110
6c2f89a
98d4627
2945df8
42307d2
bd75787
833d72a
1f26401
afa497e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 """ | ||
|
@@ -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 | ||
|
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() | ||
|
@@ -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 | ||
|
@@ -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 """ | ||
|
@@ -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 """ | ||
|
@@ -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 """ | ||
|
@@ -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 | ||
|
@@ -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 | ||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
There was a problem hiding this comment.
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?