src/erlcron.erl

%%% @copyright Erlware, LLC. All Rights Reserved.
%%%
%%% This file is provided to you under the BSD License; you may not use
%%% this file except in compliance with the License.
-module(erlcron).

-export([validate/1,
         cron/1,   cron/2,    cron/3,
         at/2,     at/3,      at/4,
         daily/2,  daily/3,   daily/4,
         weekly/3, weekly/4,  weekly/5,
         monthly/3,monthly/4, monthly/5,
         cancel/1,
         epoch/0,
         epoch_seconds/0,
         datetime/0,
         ref_datetime/0,
         set_datetime/1,
         set_datetime/2,
         reset_datetime/0,
         get_all_jobs/0,
         multi_set_datetime/1,
         multi_set_datetime/2]).

-export_type([job/0,
              job_ref/0,
              job_opts/0,
              cron_opts/0,
              job_start/0,
              job_end/0,
              run_when/0,
              callable/0,
              dow/0,
              dom/0,
              period/0,
              duration/0,
              constraint/0,
              cron_time/0,
              seconds/0,
              milliseconds/0]).


%%%===================================================================
%%% Types
%%%===================================================================

-type seconds()     :: integer().
-type milliseconds():: integer().

-type cron_time()   :: {integer(), am | pm}
                     | {integer(), integer(), am | pm}
                     | calendar:time().
-type constraint() :: {between, cron_time(), cron_time()}.
-type duration()   :: {integer(), hr | h | min | m | sec | s}.
-type period()     :: cron_time() | {every, duration()}
                                  | {every, duration(), constraint()}.
-type dom()        :: integer().
-type dow_day()    :: mon | tue | wed | thu | fri | sat | sun.
-type dow()        :: dow_day() | [dow_day()].
-type callable()   :: {M :: module(), F :: atom(), A :: [term()]} |
                      fun(() -> term()) |
                      fun((JobRef::job_ref(), calendar:datetime()) -> term()).
-type run_when()   :: {once, cron_time()}
                    | {once, seconds()}
                    | {daily, period()}
                    | {weekly, dow(), period()}
                    | {monthly, dom()|[dom()], period()}.

-type  job()      :: {run_when(), callable()}
                   | {run_when(), callable(), job_opts()}
                   | #{id => job_ref(), interval => run_when(), execute => callable(), _ => any()}.

%% should be opaque but dialyzer does not allow it
-type job_ref()   :: reference() | atom() | binary().
%% A job reference.

-type job_start() :: fun((JobRef::job_ref()) -> ignore | any()).
%% A function to be called before a job is started. If it returns the `ignore'
%% atom, the job function will not be executed.

-type job_end()   :: fun((JobRef::job_ref(),
                         Res :: {ok, term()} | {error, {Reason::term(), Stack::list()}})
                       -> term()).
%% A function to be called after a job ended. The function is passed the
%% job's result.

-type job_opts()  ::
    #{hostnames    => [binary()|string()],
      id           => term(),
      on_job_start => {Mod::atom(), Fun::atom()} | job_start(),
      on_job_end   => {Mod::atom(), Fun::atom()} | job_end()
    }.
%% Job options:
%% <dl>
%% <dt>hostnames => [Hostname]</dt>
%%   <dd>List of hostnames where the job is allowed to run</dd>
%% <dt>id => ID</dt>
%%   <dd>An identifier of the job passed to `on_job_start' and `on_job_end'
%%   callbacks. It can be any term.</dd>
%% <dt>on_job_start => {Mod, Fun} | fun(JobRef, ID) -> any()</dt>
%%   <dd>`Mod:Fun(Ref::job_ref(), ID::any())' function to call on job start.
%%   The result is ignored.</dd>
%% <dt>{on_job_start, {Mod, Fun}} | fun(JobRef, ID, Result) -> term()</dt>
%%   <dd>`Mod:Fun(Ref::job_ref(), ID::any(), Result)' function to call after
%%   a job has ended.  `Result' is `{ok, JobResult::term()}' or
%%   `{error, `{Reason, StackTrace}}' if there is an exception.</dd>
%% </dl>

-type cron_opts() :: job_opts().
%% Cron default options applicable to all jobs. See job_opts().

%%%===================================================================
%%% API
%%%===================================================================

%% @doc
%%  Check that the spec specified is valid or invalid
-spec validate(run_when()) -> ok | {error, term()}.
validate(Spec) ->
    ecrn_agent:validate(Spec).

%% @doc
%%  Adds a new job to the cron system. Jobs are described in the job()
%%  spec. It returns the JobRef that can be used to manipulate the job
%%  after it is created.

-spec cron(job()) ->
        job_ref() | ignored | already_started | {error, term()}.
cron(Job) ->
    cron(make_ref(Job), Job, #{}).

-spec cron(job()|job_ref(), job()|cron_opts()) ->
        job_ref() | ignored | already_started | {error, term()}.
cron(Job, DefOpts) when is_map(DefOpts) ->
    cron(make_ref(Job), Job, DefOpts);

cron(JobRef, Job) when (is_atom(JobRef) orelse is_reference(JobRef) orelse is_binary(JobRef)) ->
    cron(JobRef, Job, #{}).

%% @doc Schedule a job identified by a `JobRef'.
%% A job reference can be a reference, atom, or binary. If it is
%% an atom, a job will be locally registered by that name.
%% Returns false if the job is not permitted to run on the current host
-spec cron(job_ref(), job(), job_opts()) ->
        job_ref() | ignored | already_started | {error, term()}.
cron(JobRef, Job, Opts) when (is_atom(JobRef) orelse is_reference(JobRef) orelse is_binary(JobRef))
                           , is_map(Opts) ->
    ecrn_cron_sup:add_job(JobRef, Job, Opts).

%% @doc
%% Convenience method to specify a job to run once at the given time
%% or after the amount of time specified.
-spec at(cron_time() | seconds(), callable()) ->
        job_ref() | ignored | already_started | {error, term()}.
at(When, Fun) ->
    at(make_ref(), When, Fun).

-spec at(job_ref(), cron_time() | seconds(), function()) ->
        job_ref() | ignored | already_started | {error, term()}.
at(JobRef, When, Fun) ->
    cron(JobRef, {{once, When}, Fun}).

-spec at(job_ref(), cron_time() | seconds(), function(), job_opts()) ->
        job_ref() | ignored | already_started | {error, term()}.
at(JobRef, When, Fun, #{} = Opts) ->
    cron(JobRef, {{once, When}, Fun}, Opts).

%% @doc
%% Convenience method to specify a job run to run on a daily basis
%% at a specific time.
-spec daily(cron_time() | seconds(), function()) ->
        job_ref() | ignored | already_started | {error, term()}.
daily(When, Fun) ->
    daily(make_ref(), When, Fun).

-spec daily(job_ref(), cron_time() | seconds(), function()) ->
        job_ref() | ignored | already_started | {error, term()}.
daily(JobRef, When, Fun) ->
    cron(JobRef, {{daily, When}, Fun}).

-spec daily(job_ref(), cron_time() | seconds(), function(), job_opts()) ->
        job_ref() | ignored | already_started | {error, term()}.
daily(JobRef, When, Fun, #{} = Opts) ->
    cron(JobRef, {{daily, When}, Fun}, Opts).

%% @doc
%% Convenience method to specify a job run to run on a weekly basis
%% at a specific time.
-spec weekly(dow(), cron_time() | seconds(), function()) ->
        job_ref() | ignored | already_started | {error, term()}.
weekly(DOW, When, Fun) ->
    weekly(make_ref(), DOW, When, Fun).

-spec weekly(job_ref(), dow(), cron_time() | seconds(), function()) ->
        job_ref() | ignored | already_started | {error, term()}.
weekly(JobRef, DOW, When, Fun) ->
    cron(JobRef, {{weekly, DOW, When}, Fun}).

-spec weekly(job_ref(), dow(), cron_time() | seconds(), function(), job_opts()) ->
        job_ref() | ignored | already_started | {error, term()}.
weekly(JobRef, DOW, When, Fun, #{} = Opts) ->
    cron(JobRef, {{weekly, DOW, When}, Fun}, Opts).

%% @doc
%% Convenience method to specify a job run to run on a weekly basis
%% at a specific time.
-spec monthly(dom(), cron_time() | seconds(), function()) ->
        job_ref() | ignored | already_started | {error, term()}.
monthly(DOM, When, Fun) ->
    monthly(make_ref(), DOM, When, Fun).

-spec monthly(job_ref(), dom(), cron_time() | seconds(), function()) ->
        job_ref() | ignored | already_started | {error, term()}.
monthly(JobRef, DOM, When, Fun) ->
    cron(JobRef, {{monthly, DOM, When}, Fun}).

-spec monthly(job_ref(), dom(), cron_time() | seconds(), function(), job_opts()) ->
        job_ref() | ignored | already_started | {error, term()}.
monthly(JobRef, DOM, When, Fun, #{} = Opts) ->
    cron(JobRef, {{monthly, DOM, When}, Fun}, Opts).

%% @doc
%% Cancel the job specified by the jobref.
-spec cancel(job_ref()) -> boolean().
cancel(JobRef) ->
    ecrn_control:cancel(JobRef).

%% @doc
%% Get the current date time in seconds millisince epoch.
-spec epoch() -> milliseconds().
epoch() ->
    ecrn_util:epoch_milliseconds().

%% @doc
%% Get the current date time in seconds since epoch.
-spec epoch_seconds() -> seconds().
epoch_seconds() ->
    ecrn_util:epoch_seconds().

%% @doc
%% Get the current date time of the erlcron system adjusted to reference.
%%
-spec datetime() -> {calendar:datetime(), milliseconds()}.
datetime() ->
    ecrn_control:datetime().

%% @doc
%% Get the reference date time of the erlcron system.
%%
-spec ref_datetime() -> {calendar:datetime(), milliseconds()}.
ref_datetime() ->
    ecrn_control:ref_datetime().

%% @doc
%% Set the current date time of the running erlcron system using local time.
-spec set_datetime(calendar:datetime()) -> ok.
set_datetime(DateTime) ->
    set_datetime(DateTime, local).

%% @doc
%% Set the current date time of the running erlcron system using either local
%% or universal time. The `TZ` parameter must contain an atom `local|universal'.
-spec set_datetime(calendar:datetime(), local|universal) -> ok.
set_datetime({D,T} = DateTime, TZ) when tuple_size(D)==3, tuple_size(T)==3 ->
    ecrn_control:set_datetime(DateTime, TZ).

%% @doc
%% Reset the reference datetime of erlcron system to current epoch time.
-spec reset_datetime() -> ok.
reset_datetime() ->
    ecrn_control:reset_datetime().

%% Get references of all running jobs
-spec get_all_jobs() -> [job_ref()].
get_all_jobs() ->
    ecrn_reg:get_all_refs().

%% @doc
%% Set the current date time of the erlcron system running on different nodes.
-spec multi_set_datetime(calendar:datetime()) -> {Replies, BadNodes} when
    Replies :: [{node(), ok | {error, term()}}],
    BadNodes :: [node()].
multi_set_datetime({D,T} = DateTime) when tuple_size(D)==3, tuple_size(T)==3 ->
    ecrn_control:multi_set_datetime([node()|nodes()], DateTime).

%% @doc
%% Set the current date time of the erlcron system running on the
%% specified nodes
-spec multi_set_datetime([node()], calendar:datetime()) -> {Replies, BadNodes} when
    Replies :: [{node(), ok | {error, term()}}],
    BadNodes :: [node()].
multi_set_datetime(Nodes, DateTime) when is_list(Nodes), tuple_size(DateTime)==2 ->
    ecrn_control:multi_set_datetime(Nodes, DateTime).

make_ref(#{id := ID}) when is_atom(ID); is_binary(ID); is_reference(ID) ->
    ID;
make_ref({_When, _Callable, #{id := ID}}) when is_atom(ID); is_binary(ID); is_reference(ID) ->
    ID;
make_ref({_When, _Callable, #{}}) ->
    make_ref();
make_ref({_When, _Callable}) ->
    make_ref().