defmodule Crontab.Scheduler do
@moduledoc """
This module provides the functionality to retrieve the next run date or the
previous run date from a `%CronExpression{}`.
"""
import Crontab.DateChecker
alias Crontab.CronExpression
alias Crontab.DateHelper
@typep maybe(success, error) :: {:ok, success} | {:error, error}
@type direction :: :increment | :decrement
@type result :: maybe(NaiveDateTime.t(), any)
# TODO: Remove if when requiring Elixir 1.10 + only
if function_exported?(Application, :compile_env, 3) do
@max_runs Application.compile_env(:crontab, :max_runs, 10_000)
else
# credo:disable-for-next-line Credo.Check.Warning.ApplicationConfigInModuleAttribute
@max_runs Application.get_env(:crontab, :max_runs, 10_000)
end
@doc """
This function provides the functionality to retrieve the next run date from a
`%Crontab.CronExpression{}`.
## Examples
iex> Crontab.Scheduler.get_next_run_date(%Crontab.CronExpression{}, ~N[2002-01-13 23:00:07])
{:ok, ~N[2002-01-13 23:01:00]}
iex> Crontab.Scheduler.get_next_run_date(%Crontab.CronExpression{year: [{:/, :*, 9}]}, ~N[2002-01-13 23:00:07])
{:ok, ~N[2007-01-01 00:00:00]}
iex> Crontab.Scheduler.get_next_run_date %Crontab.CronExpression{reboot: true}
** (RuntimeError) Special identifier @reboot is not supported.
"""
@spec get_next_run_date(CronExpression.t(), NaiveDateTime.t(), integer) :: result
def get_next_run_date(
cron_expression,
date \\ DateTime.to_naive(DateTime.utc_now()),
max_runs \\ @max_runs
)
def get_next_run_date(%CronExpression{reboot: true}, _, _),
do: raise("Special identifier @reboot is not supported.")
def get_next_run_date(cron_expression = %CronExpression{extended: false}, date, max_runs) do
case get_run_date(cron_expression, clean_date(date, :seconds), max_runs, :increment) do
{:ok, date} -> {:ok, date}
error = {:error, _} -> error
end
end
def get_next_run_date(cron_expression = %CronExpression{extended: true}, date, max_runs) do
get_run_date(cron_expression, clean_date(date, :microseconds), max_runs, :increment)
end
@doc """
This function provides the functionality to retrieve the next run date from a
`%Crontab.CronExpression{}`.
### Examples
iex> Crontab.Scheduler.get_next_run_date!(%Crontab.CronExpression{}, ~N[2002-01-13 23:00:07])
~N[2002-01-13 23:01:00]
iex> Crontab.Scheduler.get_next_run_date!(%Crontab.CronExpression{year: [1990]}, ~N[2002-01-13 23:00:07])
** (RuntimeError) No compliant date was found for your interval.
iex> Crontab.Scheduler.get_next_run_date!(%Crontab.CronExpression{year: [{:/, :*, 9}]}, ~N[2002-01-13 23:00:07])
~N[2007-01-01 00:00:00]
iex> Crontab.Scheduler.get_next_run_date! %Crontab.CronExpression{reboot: true}
** (RuntimeError) Special identifier @reboot is not supported.
"""
@spec get_next_run_date!(CronExpression.t(), NaiveDateTime.t(), integer) ::
NaiveDateTime.t() | no_return
def get_next_run_date!(
cron_expression,
date \\ DateTime.to_naive(DateTime.utc_now()),
max_runs \\ @max_runs
) do
case get_next_run_date(cron_expression, date, max_runs) do
{:ok, result} -> result
{:error, error} -> raise error
end
end
@doc """
Find the next execution dates relative to a given date from a `%CronExpression{}`.
## Examples
iex> Enum.take(Crontab.Scheduler.get_next_run_dates(
...> %Crontab.CronExpression{extended: true}, ~N[2016-12-17 00:00:00]), 3)
[
~N[2016-12-17 00:00:00],
~N[2016-12-17 00:00:01],
~N[2016-12-17 00:00:02]
]
iex> Enum.take(Crontab.Scheduler.get_next_run_dates(%Crontab.CronExpression{}, ~N[2016-12-17 00:00:00]), 3)
[
~N[2016-12-17 00:00:00],
~N[2016-12-17 00:01:00],
~N[2016-12-17 00:02:00]
]
iex> Enum.take(Crontab.Scheduler.get_next_run_dates(%Crontab.CronExpression{
...> year: [2017], month: [1], day: [1], hour: [0], minute: [1]}, ~N[2016-12-17 00:00:00]), 3)
[~N[2017-01-01 00:01:00]]
iex> Enum.take(Crontab.Scheduler.get_next_run_dates(%Crontab.CronExpression{reboot: true}), 3)
** (RuntimeError) Special identifier @reboot is not supported.
"""
@spec get_next_run_dates(CronExpression.t(), NaiveDateTime.t()) :: Enumerable.t()
def get_next_run_dates(cron_expression, date \\ DateTime.to_naive(DateTime.utc_now()))
def get_next_run_dates(cron_expression = %CronExpression{extended: false}, date) do
_get_next_run_dates(cron_expression, date, fn date -> NaiveDateTime.add(date, 60, :second) end)
end
def get_next_run_dates(cron_expression = %CronExpression{extended: true}, date) do
_get_next_run_dates(cron_expression, date, fn date -> NaiveDateTime.add(date, 1, :second) end)
end
@spec _get_next_run_dates(CronExpression.t(), NaiveDateTime.t(), function) :: Enumerable.t()
defp _get_next_run_dates(cron_expression, date, advance_date) do
Stream.unfold(date, fn previous_date ->
case get_next_run_date(cron_expression, previous_date) do
{:ok, new_date} -> {new_date, advance_date.(new_date)}
_ -> nil
end
end)
end
@doc """
This function provides the functionality to retrieve the previous run date
from a `%Crontab.CronExpression{}`.
## Examples
iex> Crontab.Scheduler.get_previous_run_date %Crontab.CronExpression{}, ~N[2002-01-13 23:00:07]
{:ok, ~N[2002-01-13 23:00:00]}
iex> Crontab.Scheduler.get_previous_run_date %Crontab.CronExpression{
...> year: [{:/, :*, 9}]}, ~N[2002-01-13 23:00:07]
{:ok, ~N[1998-12-31 23:59:00]}
iex> Crontab.Scheduler.get_previous_run_date %Crontab.CronExpression{reboot: true}
** (RuntimeError) Special identifier @reboot is not supported.
"""
@spec get_previous_run_date(CronExpression.t(), NaiveDateTime.t(), integer) :: result
def get_previous_run_date(
cron_expression,
date \\ DateTime.to_naive(DateTime.utc_now()),
max_runs \\ @max_runs
)
def get_previous_run_date(%CronExpression{reboot: true}, _, _),
do: raise("Special identifier @reboot is not supported.")
def get_previous_run_date(cron_expression = %CronExpression{extended: false}, date, max_runs) do
case get_run_date(cron_expression, date, max_runs, :decrement) do
{:ok, date} -> {:ok, DateHelper.beginning_of(date, :minute)}
error = {:error, _} -> error
end
end
def get_previous_run_date(cron_expression = %CronExpression{extended: true}, date, max_runs) do
get_run_date(cron_expression, date, max_runs, :decrement)
end
@doc """
This function provides the functionality to retrieve the previous run date
from a `%Crontab.CronExpression{}`.
## Examples
iex> Crontab.Scheduler.get_previous_run_date! %Crontab.CronExpression{}, ~N[2002-01-13 23:00:07]
~N[2002-01-13 23:00:00]
iex> Crontab.Scheduler.get_previous_run_date!(%Crontab.CronExpression{year: [2100]}, ~N[2002-01-13 23:00:07])
** (RuntimeError) No compliant date was found for your interval.
iex> Crontab.Scheduler.get_previous_run_date! %Crontab.CronExpression{
...> year: [{:/, :*, 9}]}, ~N[2002-01-13 23:00:07]
~N[1998-12-31 23:59:00]
iex> Crontab.Scheduler.get_previous_run_date! %Crontab.CronExpression{reboot: true}
** (RuntimeError) Special identifier @reboot is not supported.
"""
@spec get_previous_run_date!(CronExpression.t(), NaiveDateTime.t(), integer) ::
NaiveDateTime.t() | no_return
def get_previous_run_date!(
cron_expression,
date \\ DateTime.to_naive(DateTime.utc_now()),
max_runs \\ @max_runs
) do
case get_previous_run_date(cron_expression, date, max_runs) do
{:ok, result} -> result
{:error, error} -> raise error
end
end
@doc """
Find the previous n execution dates relative to a given date from a `%CronExpression{}`.
## Examples
iex> Enum.take(Crontab.Scheduler.get_previous_run_dates(
...> %Crontab.CronExpression{extended: true}, ~N[2016-12-17 00:00:00]), 3)
[
~N[2016-12-17 00:00:00],
~N[2016-12-16 23:59:59],
~N[2016-12-16 23:59:58]
]
iex> Enum.take(Crontab.Scheduler.get_previous_run_dates(%Crontab.CronExpression{}, ~N[2016-12-17 00:00:00]), 3)
[
~N[2016-12-17 00:00:00],
~N[2016-12-16 23:59:00],
~N[2016-12-16 23:58:00]
]
iex> Enum.take(Crontab.Scheduler.get_previous_run_dates(%Crontab.CronExpression{
...> year: [2017], month: [1], day: [1], hour: [0], minute: [1]}, ~N[2016-12-17 00:00:00]), 3)
[]
iex> Enum.take(Crontab.Scheduler.get_previous_run_dates(%Crontab.CronExpression{reboot: true}), 3)
** (RuntimeError) Special identifier @reboot is not supported.
"""
@spec get_previous_run_dates(CronExpression.t(), NaiveDateTime.t()) :: Enumerable.t()
def get_previous_run_dates(cron_expression, date \\ DateTime.to_naive(DateTime.utc_now()))
def get_previous_run_dates(cron_expression = %CronExpression{extended: false}, date) do
_get_previous_run_dates(cron_expression, date, fn date ->
NaiveDateTime.add(date, -60, :second)
end)
end
def get_previous_run_dates(cron_expression = %CronExpression{extended: true}, date) do
_get_previous_run_dates(cron_expression, date, fn date ->
NaiveDateTime.add(date, -1, :second)
end)
end
@spec _get_previous_run_dates(CronExpression.t(), NaiveDateTime.t(), function) :: Enumerable.t()
defp _get_previous_run_dates(cron_expression, date, advance_date) do
Stream.unfold(date, fn previous_date ->
case get_previous_run_date(cron_expression, previous_date) do
{:ok, new_date} -> {new_date, advance_date.(new_date)}
_ -> nil
end
end)
end
@spec get_run_date(
CronExpression.t() | CronExpression.condition_list(),
NaiveDateTime.t(),
integer,
direction
) :: result
defp get_run_date(_, _, 0, _) do
{:error, "No compliant date was found for your interval."}
end
defp get_run_date(cron_expression = %CronExpression{extended: false}, date, max_runs, direction) do
cron_expression
|> CronExpression.to_condition_list()
|> Enum.reverse()
|> get_run_date(DateHelper.beginning_of(date, :minute), max_runs, direction)
end
defp get_run_date(cron_expression = %CronExpression{extended: true}, date, max_runs, direction) do
cron_expression
|> CronExpression.to_condition_list()
|> Enum.reverse()
|> get_run_date(DateHelper.beginning_of(date, :second), max_runs, direction)
end
defp get_run_date(conditions, date, max_runs, direction) do
case search_and_correct_date(conditions, date, direction) do
{:ok, corrected_date} ->
{:ok, corrected_date}
{:error, :impossible} ->
{:error, "No compliant date was found for your interval."}
{:error, {:not_found, corrected_date}} ->
get_run_date(conditions, corrected_date, max_runs - 1, direction)
end
end
@spec search_and_correct_date(CronExpression.condition_list(), NaiveDateTime.t(), direction) ::
maybe(NaiveDateTime.t(), {:not_found, NaiveDateTime.t()} | :impossible)
defp search_and_correct_date(
[{:year, [target_year]} | _],
%NaiveDateTime{year: from_year},
:increment
)
when is_integer(target_year) and target_year < from_year do
{:error, :impossible}
end
defp search_and_correct_date(
[{:year, [target_year]} | _],
%NaiveDateTime{year: from_year},
:decrement
)
when is_integer(target_year) and target_year > from_year do
{:error, :impossible}
end
defp search_and_correct_date([{interval, conditions} | tail], date, direction) do
if matches_date?(interval, conditions, date) do
search_and_correct_date(tail, date, direction)
else
case correct_date(interval, date, direction) do
{:ok, corrected_date} ->
{:error, {:not_found, corrected_date}}
# Prevent to reach lower bound (year 0)
{:error, _} ->
{:error, {:not_found, date}}
end
end
end
defp search_and_correct_date([], date, _), do: {:ok, date}
@spec correct_date(CronExpression.interval(), NaiveDateTime.t(), direction) ::
maybe(NaiveDateTime.t(), any)
defp correct_date(:second, date, :increment), do: {:ok, date |> NaiveDateTime.add(1, :second)}
defp correct_date(:minute, date, :increment),
do: {:ok, date |> NaiveDateTime.add(60, :second) |> DateHelper.beginning_of(:minute)}
defp correct_date(:hour, date, :increment),
do: {:ok, date |> NaiveDateTime.add(3_600, :second) |> DateHelper.beginning_of(:hour)}
defp correct_date(:day, date, :increment),
do: {:ok, date |> NaiveDateTime.add(86_400, :second) |> DateHelper.beginning_of(:day)}
defp correct_date(:month, date, :increment),
do: {:ok, date |> DateHelper.inc_month() |> DateHelper.beginning_of(:month)}
defp correct_date(:weekday, date, :increment),
do: {:ok, date |> NaiveDateTime.add(86_400, :second) |> DateHelper.beginning_of(:day)}
defp correct_date(:year, %NaiveDateTime{year: 9_999}, :increment), do: {:error, :upper_bound}
defp correct_date(:year, date, :increment),
do: {:ok, date |> DateHelper.inc_year() |> DateHelper.beginning_of(:year)}
defp correct_date(:second, date, :decrement),
do: {:ok, date |> NaiveDateTime.add(-1, :second) |> DateHelper.beginning_of(:second)}
defp correct_date(:minute, date, :decrement),
do:
{:ok,
date
|> NaiveDateTime.add(-60, :second)
|> DateHelper.end_of(:minute)
|> DateHelper.beginning_of(:second)}
defp correct_date(:hour, date, :decrement),
do:
{:ok,
date
|> NaiveDateTime.add(-3_600, :second)
|> DateHelper.end_of(:hour)
|> DateHelper.beginning_of(:second)}
defp correct_date(:day, date, :decrement),
do:
{:ok,
date
|> NaiveDateTime.add(-86_400, :second)
|> DateHelper.end_of(:day)
|> DateHelper.beginning_of(:second)}
defp correct_date(:month, date, :decrement),
do:
{:ok,
date
|> DateHelper.dec_month()
|> DateHelper.end_of(:month)
|> DateHelper.beginning_of(:second)}
defp correct_date(:weekday, date, :decrement),
do:
{:ok,
date
|> NaiveDateTime.add(-86_400, :second)
|> DateHelper.end_of(:day)
|> DateHelper.beginning_of(:second)}
defp correct_date(:year, %NaiveDateTime{year: 0}, :decrement), do: {:error, :lower_bound}
defp correct_date(:year, date, :decrement),
do:
{:ok,
date
|> DateHelper.dec_year()
|> DateHelper.end_of(:year)
|> DateHelper.beginning_of(:second)}
@spec clean_date(NaiveDateTime.t(), :seconds | :microseconds) :: NaiveDateTime.t()
defp clean_date(date = %NaiveDateTime{microsecond: {0, _}}, :microseconds), do: date
defp clean_date(date = %NaiveDateTime{}, :microseconds) do
date
|> Map.put(:microsecond, {0, 0})
|> NaiveDateTime.add(1, :second)
end
defp clean_date(date = %NaiveDateTime{}, :seconds) do
clean_microseconds = clean_date(date, :microseconds)
case clean_microseconds do
%NaiveDateTime{second: 0} -> clean_microseconds
_ -> NaiveDateTime.add(clean_microseconds, 60, :second)
end
end
end