Current section

Files

Jump to
ecron src ecron.erl
Raw

src/ecron.erl

-module(ecron).
-include("ecron.hrl").
-export([add/6, add/5, add/4, add/3]).
-export([delete/1]).
-export([deactivate/1, activate/1]).
-export([statistic/0, statistic/1]).
-export([parse_spec/2, parse_spec/1]).
-export([valid_datetime/2]).
-type name() :: term().
-type crontab_spec() :: crontab() | string() | binary() | 1..?MAX_TIMEOUT.
-type crontab() :: #{second => '*' | [0..59 | {0..58, 1..59}, ...],
minute => '*' | [0..59 | {0..58, 1..59}, ...],
hour => '*' | [0..23, ...],
month => '*' | [1..12 | {1..11, 2..12}, ...],
day_of_month => '*' | [1..31 | {1..30, 2..31}, ...],
day_of_week => '*' | [0..6 | {0..5, 1..6}, ...]}.
-type ecron() :: #{name => name(),
crontab => crontab(),
start_time => calendar:rfc3339_string() | unlimited,
end_time => calendar:rfc3339_string() | unlimited,
mfa => mfa(),
type => cron | every}.
-type status() :: waiting | running | deactivate | activate | already_ended.
-type statistic() :: #{ecron => ecron(),
status => status(),
failed => non_neg_integer(),
ok => non_neg_integer(),
results => [term()],
run_microsecond => [pos_integer()],
time_zone => local | utc,
worker => pid(),
next => [calendar:datetime()]}.
-type parse_error() :: invalid_time | invalid_spec | month | day_of_month | day_of_week | hour | minute | second.
-type start_datetime() :: unlimited | calendar:datetime().
-type end_datetime() :: unlimited | calendar:datetime().
-spec add(name(), crontab_spec(), mfa()) ->
{ok, name()} | {error, parse_error(), term()} | {error, already_exist}.
add(JobName, Spec, MFA) ->
add(JobName, Spec, MFA, unlimited, unlimited, [{singleton, true}]).
-spec add(crontab_spec(), mfa(), start_datetime(), end_datetime()) ->
{ok, name()} | {error, parse_error(), term()}.
add(Spec, MFA, Start, End) ->
add(erlang:make_ref(), Spec, MFA, Start, End, [{singleton, true}]).
-spec add(name(), crontab_spec(), mfa(), start_datetime(), end_datetime()) ->
{ok, name()} | {error, parse_error(), term()} | {error, already_exist}.
add(JobName, Spec, MFA, Start, End) ->
add(JobName, Spec, MFA, Start, End, [{singleton, true}]).
-spec add(name(), crontab_spec(), mfa(), start_datetime(), end_datetime(), proplists:proplists()) ->
{ok, name()} | {error, parse_error(), term()} | {error, already_exist}.
add(JobName, Spec, MFA, Start, End, Option) ->
case valid_datetime(Start, End) of
true ->
case parse_spec(Spec) of
{ok, Type, Crontab} ->
ecron_tick:add(#{
type => Type, name => JobName,
crontab => Crontab, mfa => MFA,
start_time => Start, end_time => End
}, Option);
ErrParse -> ErrParse
end;
false -> {error, invalid_time, {Start, End}}
end.
-spec delete(name()) -> ok.
delete(JobName) -> ecron_tick:delete(JobName).
-spec deactivate(name()) -> ok | {error, not_found}.
deactivate(JobName) -> ecron_tick:deactivate(JobName).
-spec activate(name()) -> ok | {error, already_ended | not_found}.
activate(JobName) -> ecron_tick:activate(JobName).
-spec statistic(name()) -> {ok, statistic()} | {error, not_found}.
statistic(JobName) -> ecron_tick:statistic(JobName).
-spec statistic() -> [statistic()].
statistic() -> ecron_tick:statistic().
-spec parse_spec(crontab_spec(), pos_integer()) ->
{ok, #{type => cron | every, crontab => crontab_spec(), next => [calendar:rfc3339_string()]}} |
{error, atom(), term()}.
parse_spec({ok, Type, JobSpec}, Num) ->
Job = #{type => Type, crontab => JobSpec},
Next = ecron_tick:predict_datetime(Job, Num),
{ok, Job#{next => Next}};
parse_spec({error, _Field, _Value} = Error, _Num) -> Error;
parse_spec(Spec, Num) when is_integer(Num) andalso Num > 0 ->
parse_spec(parse_spec(Spec), Num).
%%%===================================================================
%%% Internal functions
%%%===================================================================
valid_datetime(Start, End) ->
case valid_datetime(Start) andalso valid_datetime(End) of
true when Start =/= unlimited andalso End =/= unlimited ->
EndSec = calendar:datetime_to_gregorian_seconds(End),
StartSec = calendar:datetime_to_gregorian_seconds(Start),
EndSec > StartSec;
Res -> Res
end.
valid_datetime(unlimited) -> true;
valid_datetime({Date, {H, M, S}}) ->
(is_integer(H) andalso H >= 0 andalso H =< 23) andalso
(is_integer(M) andalso M >= 0 andalso M =< 59) andalso
(is_integer(S) andalso S >= 0 andalso H =< 59) andalso
calendar:valid_date(Date);
valid_datetime(_ErrFormat) -> false.
parse_spec("@yearly") -> parse_spec("0 0 1 1 *"); % Run once a year, midnight, Jan. 1st
parse_spec("@annually") -> parse_spec("0 0 1 1 *"); % Same as @yearly
parse_spec("@monthly") -> parse_spec("0 0 1 * *"); % Run once a month, midnight, first of month
parse_spec("@weekly") -> parse_spec("0 0 * * 0"); % Run once a week, midnight between Sat/Sun
parse_spec("@midnight") -> parse_spec("0 0 * * *"); % Run once a day, midnight
parse_spec("@daily") -> parse_spec("0 0 * * *"); % Same as @midnight
parse_spec("@hourly") -> parse_spec("0 * * * *"); % Run once an hour, beginning of hour
parse_spec("@minutely") -> parse_spec("* * * * *");
parse_spec(Bin) when is_binary(Bin) -> parse_spec(binary_to_list(Bin));
parse_spec(List) when is_list(List) ->
case string:tokens(string:lowercase(List), " ") of
[_S, _M, _H, _DOM, _Mo, _DOW] = Cron -> parse_cron_spec(Cron);
[_M, _H, _DOM, _Mo, _DOW] = Cron -> parse_cron_spec(["0" | Cron]);
["@every", Sec] -> parse_every_spec(Sec);
_ -> {error, invalid_spec, List}
end;
parse_spec(Spec) when is_map(Spec) ->
{Months, NewSpec} = take(month, Spec),
case unzip(Months, 1, 12, []) of
{ok, EMonths} ->
List = [{second, 0, 59}, {minute, 0, 59}, {hour, 0, 23},
{day_of_month, 1, get_max_day_of_months(EMonths)},
{day_of_week, 0, 6}],
format_map_spec(List, NewSpec, #{month => zip(EMonths)});
error -> {error, month, Months}
end;
parse_spec(Second) when is_integer(Second) andalso Second =< ?MAX_TIMEOUT ->
{ok, every, Second};
parse_spec(Spec) -> {error, invalid_spec, Spec}.
parse_cron_spec([Second, Minute, Hour, DayOfMonth, Month, DayOfWeek]) ->
case parse_field(Month, 1, 12) of
{ok, Months} ->
Fields = [
{second, Second, 0, 59},
{minute, Minute, 0, 59},
{hour, Hour, 0, 23},
{day_of_month, DayOfMonth, 1, get_max_day_of_months(Months)},
{day_of_week, DayOfWeek, 0, 6}],
parse_fields(Fields, #{month => Months});
error -> {error, month, Month}
end.
parse_fields([], Acc) -> {ok, cron, Acc};
parse_fields([{Key, Spec, Min, Max} | Rest], Acc) ->
case parse_field(Spec, Min, Max) of
{ok, V} -> parse_fields(Rest, Acc#{Key => V});
error -> {error, Key, Spec}
end.
parse_field("*", _Min, _Max) -> {ok, '*'};
parse_field(Value, MinLimit, MaxLimit) ->
parse_field(string:tokens(Value, ","), MinLimit, MaxLimit, []).
parse_field([], _MinLimit, _MaxLimit, Acc) -> {ok, zip(lists:usort(Acc))};
parse_field([Field | Fields], MinL, MaxL, Acc) ->
case string:tokens(Field, "-") of
[Field] ->
case string:tokens(Field, "/") of
[_] -> % Integer
Int = field_to_int(Field),
case Int >= MinL andalso Int =< MaxL of
true -> parse_field(Fields, MinL, MaxL, [Int | Acc]);
false -> error
end;
["*", StepStr] -> % */Step -> MinLimit~MaxLimit/Step
case field_to_int(StepStr) of
Step when Step > 0 ->
NewAcc = lists:seq(MinL, MaxL, Step) ++ Acc,
parse_field(Fields, MinL, MaxL, NewAcc);
_ -> error
end;
[MinStr, StepStr] -> % Min/Step -> Min~MaxLimit/Step
Min = field_to_int(MinStr),
Step = field_to_int(StepStr),
case Min >= MinL andalso Min =< MaxL andalso Step > 0 of
true ->
NewAcc = lists:seq(Min, MaxL, Step) ++ Acc,
parse_field(Fields, MinL, MaxL, NewAcc);
false -> error
end;
_ -> error
end;
[MinStr, MaxStepStr] ->
case field_to_int(MinStr) of
Min when Min >= MinL andalso Min =< MaxL -> % Min-Max/Step -> Min~Max/Step
{Max, Step} =
case string:tokens(MaxStepStr, "/") of
[_] -> {field_to_int(MaxStepStr), 1};
[MaxStr, StepStr] -> {field_to_int(MaxStr), field_to_int(StepStr)};
_ -> {-1, -1} %% error
end,
case Max >= MinL andalso Max >= Min andalso Step > 0 of
true ->
New = lists:seq(Min, Max, Step),
case lists:max(New) =< MaxL of
true -> parse_field(Fields, MinL, MaxL, New ++ Acc);
false -> error
end;
false -> error
end;
_ -> error
end;
_ -> error
end.
zip('*') -> '*';
zip([T | Rest]) -> zip(Rest, T + 1, [T], []).
zip([], _, [Single], Acc) -> lists:reverse([Single | Acc]);
zip([], _, [One, Two], Acc) -> lists:reverse([One, Two | Acc]);
zip([], _, Buffer, Acc) -> lists:reverse([{lists:min(Buffer), lists:max(Buffer)} | Acc]);
zip([L | Rest], L, Buffer, Acc) -> zip(Rest, L + 1, [L | Buffer], Acc);
zip([F | Rest], _Last, [Single], Acc) -> zip(Rest, F + 1, [F], [Single | Acc]);
zip([F | Rest], _Last, [One, Two], Acc) -> zip(Rest, F + 1, [F], [One, Two | Acc]);
zip([F | Rest], _Last, Buffer, Acc) ->
zip(Rest, F + 1, [F], [{lists:min(Buffer), lists:max(Buffer)} | Acc]).
unzip('*', _MinLimit, _MaxLimit, _Acc) -> {ok, '*'};
unzip([], _MinLimit, _MaxLimit, Acc) -> {ok, lists:usort(Acc)};
unzip([{Min, Max} | List], MinL, MaxL, Acc) ->
NewMin = field_to_int(Min),
NewMax = field_to_int(Max),
case NewMax >= NewMin andalso NewMax =< MaxL andalso NewMin >= MinL of
true -> unzip(List, MinL, MaxL, lists:seq(NewMin, NewMax) ++ Acc);
false -> error
end;
unzip([Int | List], MinL, MaxL, Acc) ->
case field_to_int(Int) of
V when V >= MinL andalso V =< MaxL -> unzip(List, MinL, MaxL, [V | Acc]);
_ -> error
end.
parse_every_spec(SecSpec) ->
LowerSecSpec = string:lowercase(SecSpec),
List = [{"d", 24 * 3600}, {"h", 3600}, {"m", 60}, {"s", 1}],
case parse_every(List, LowerSecSpec, 0) of
{ok, Sec} when Sec > 0 andalso Sec =< ?MAX_TIMEOUT -> {ok, every, Sec};
{ok, Sec} -> {error, second, Sec};
error -> {error, second, SecSpec}
end.
parse_every(_, "", Sum) -> {ok, Sum};
parse_every([], _, _Sum) -> error;
parse_every([{Sep, Index} | Rest], Spec, Sum) ->
case parse_every(Spec, Sep) of
{Val, NewSpec} -> parse_every(Rest, NewSpec, Val * Index + Sum);
error -> error
end.
parse_every(Spec, Seps) ->
case string:tokens(Spec, Seps) of
[Spec] -> {0, Spec};
[Str, S] ->
case field_to_int(Str) of
Value when Value >= 0 -> {Value, S};
_ -> error
end;
[Str] ->
case field_to_int(Str) of
Value when Value >= 0 -> {Value, ""};
_ -> error
end;
_ -> error
end.
get_max_day_of_months('*') -> 31;
get_max_day_of_months(List) -> max_day_of_months(List, 29).
max_day_of_months([], Max) -> Max;
max_day_of_months(_, 31) -> 31;
max_day_of_months([{_Min, _Max} | _List], _OldMax) -> 31; %% because Max - Min >= 2
max_day_of_months([Int | List], Max) ->
NewMax = erlang:max(Max, last_day_of_month(Int)),
max_day_of_months(List, NewMax).
last_day_of_month(2) -> 29;
last_day_of_month(4) -> 30;
last_day_of_month(6) -> 30;
last_day_of_month(9) -> 30;
last_day_of_month(11) -> 30;
last_day_of_month(M) when is_integer(M), M > 0, M < 13 -> 31.
-define(Alphabet, #{
"sun" => 0, "mon" => 1, "tue" => 2, "wed" => 3, "thu" => 4, "fir" => 5, "sat" => 6,
"jan" => 1, "feb" => 2, "mar" => 3, "apr" => 4, "may" => 5, "jun" => 6,
"jul" => 7, "aug" => 8, "sep" => 9, "oct" => 10, "nov" => 11, "dec" => 12}).
field_to_int(Int) when is_integer(Int) -> Int;
field_to_int(List) when is_list(List) ->
case maps:find(List, ?Alphabet) of
error ->
case string:list_to_integer(List) of
{Int, []} -> Int;
_ -> -1 %% error
end;
{ok, Int} -> Int
end.
format_map_spec([], Old, New) when Old =:= #{} -> {ok, cron, New};
format_map_spec([], Old, _New) -> {error, maps:keys(Old), maps:values(Old)};
format_map_spec([{Key, Min, Max} | List], Old, New) ->
{Value, Old1} = take(Key, Old),
case unzip(Value, Min, Max, []) of
{ok, EValue} -> format_map_spec(List, Old1, New#{Key => zip(EValue)});
error -> {error, Key, Value}
end.
take(Key, Spec) ->
case maps:take(Key, Spec) of
error when Key =:= second -> {[0], Spec};
error -> {'*', Spec};
Res -> Res
end.
%% For PropEr Test
-ifdef(TEST).
-compile(export_all).
-endif.