├── rebar.lock ├── .gitignore ├── src ├── internal.hrl ├── erlcron.app.src ├── ecrn_util.erl ├── ecrn_sup.erl ├── ecrn_app.erl ├── ecrn_control.erl ├── ecrn_cron_sup.erl ├── ecrn_reg.erl ├── erlcron.erl └── ecrn_agent.erl ├── .github ├── ISSUE_TEMPLATE │ ├── other_issues.md │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── ci.yml ├── include └── erlcron.hrl ├── rebar.config ├── Makefile ├── LICENSE ├── test ├── ecrn_startup_test.erl └── ecrn_test.erl └── README.md /rebar.lock: -------------------------------------------------------------------------------- 1 | []. 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ebin/*.beam 2 | ebin/*.app 3 | deps/ 4 | doc/ 5 | _build 6 | rebar3 7 | TEST*.xml 8 | .deps_plt 9 | .eunit/ 10 | .rebar/ 11 | -------------------------------------------------------------------------------- /src/internal.hrl: -------------------------------------------------------------------------------- 1 | %%% @copyright Erlware, LLC. All Rights Reserved. 2 | %%% 3 | %%% This file is provided to you under the BSD License; you may not use 4 | %%% this file except in compliance with the License. 5 | -define(ONE_DAY, (24 * 60 * 60)). 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/other_issues.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Other Issues 3 | about: Something that's are not covered by the other templates 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/erlcron.app.src: -------------------------------------------------------------------------------- 1 | {application,erlcron, 2 | [{description,"Scheduled execution of Erlang functions"}, 3 | {vsn,"1.2.3"}, 4 | {modules,[]}, 5 | {registered,[ecrn_agent]}, 6 | {applications,[kernel,stdlib]}, 7 | {mod,{ecrn_app,[]}}, 8 | {licenses,["BSD"]}, 9 | {links,[{"Github","https://github.com/erlware/erlcron"}]}]}. 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: [ $default-branch ] 7 | release: 8 | types: 9 | - created 10 | 11 | jobs: 12 | test: 13 | name: "Erlang Test" 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | otp: [24, 25] 18 | fail-fast: false 19 | container: 20 | image: erlang:${{ matrix.otp }} 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Test 24 | run: make 25 | -------------------------------------------------------------------------------- /include/erlcron.hrl: -------------------------------------------------------------------------------- 1 | %% compatibility 2 | -ifdef(OTP_RELEASE). %% this implies 21 or higher 3 | -define(EXCEPTION(Class, Reason, Stacktrace), Class:Reason:Stacktrace). 4 | -define(GET_STACK(Stacktrace), Stacktrace). 5 | -include_lib("kernel/include/logger.hrl"). 6 | -else. 7 | -define(EXCEPTION(Class, Reason, _), Class:Reason). 8 | -define(GET_STACK(_), erlang:get_stacktrace()). 9 | -define(LOG_ERROR(Report), error_logger:error_report(Report)). 10 | -define(LOG_WARNING(Report), error_logger:warning_report(Report)). 11 | -define(LOG_INFO(Report), error_logger:info_report(Report)). 12 | -endif. 13 | -------------------------------------------------------------------------------- /rebar.config: -------------------------------------------------------------------------------- 1 | %% -*- mode: Erlang; fill-column: 80; comment-column: 75; -*- 2 | %% Dependencies ================================================================ 3 | {deps, []}. 4 | 5 | %% Rebar Plugins =============================================================== 6 | {plugins, [rebar3_hex, rebar3_ex_doc]}. 7 | 8 | %% Compiler Options ============================================================ 9 | {erl_opts, [debug_info, warnings_as_errors]}. 10 | 11 | %% EUnit ======================================================================= 12 | {eunit_opts, [no_tty]}. 13 | 14 | {cover_enabled, true}. 15 | {cover_print_enabled, true}. 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report of bug for erlcron 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | ## `erlcron` version 10 | [Put release version here and update tag link(0.0.0) ...](https://github.com/erlware/erlcron.git) 11 | 12 | ## `OS` version 13 | 14 | 15 | ## Steps to reproduce 16 | 17 | 18 | ## Current behavior 19 | 20 | 21 | ## Expected behavior 22 | 23 | 24 | ## Config 25 | 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for erlcron 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | ## `erlcron` version 10 | [Put release version here and update tag link(0.0.0)...](https://github.com/erlware/erlcron.git) 11 | 12 | ## `OS` version 13 | 14 | 15 | ## Description 16 | * **Motivation** 17 | 18 | * **Proposal** 19 | 20 | 21 | ## Current behavior 22 | 23 | 24 | ## Expected behavior 25 | 26 | 27 | ## Config 28 | 29 | -------------------------------------------------------------------------------- /src/ecrn_util.erl: -------------------------------------------------------------------------------- 1 | %%% @copyright Erlware, LLC. All Rights Reserved. 2 | %%% 3 | %%% This file is provided to you under the BSD License; you may not use 4 | %%% this file except in compliance with the License. 5 | %%%------------------------------------------------------------------- 6 | %%% @doc 7 | %%% Utility functions for the erlcron system 8 | -module(ecrn_util). 9 | 10 | -export([epoch_seconds/0, epoch_milliseconds/0]). 11 | -export([epoch_to_time_string/1, epoch_to_datetime_string/1]). 12 | -export([universaltime_to_epoch/1, localtime_to_epoch/1]). 13 | 14 | %%%=================================================================== 15 | %%% API 16 | %%%=================================================================== 17 | -spec epoch_seconds() -> erlcron:seconds(). 18 | epoch_seconds() -> 19 | erlang:system_time(seconds). 20 | 21 | -spec epoch_milliseconds() -> erlcron:milliseconds(). 22 | epoch_milliseconds() -> 23 | erlang:system_time(millisecond). 24 | 25 | -spec epoch_to_time_string(erlcron:milliseconds()) -> string(). 26 | epoch_to_time_string(Epoch) when is_integer(Epoch) -> 27 | DT = erlang:posixtime_to_universaltime(Epoch div 1000), 28 | {_, {H,M,S}} = erlang:universaltime_to_localtime(DT), 29 | fmt("~.2.0w:~.2.0w:~.2.0w.~.3.0w", [H,M,S, Epoch rem 1000]). 30 | 31 | -spec epoch_to_datetime_string(erlcron:milliseconds()) -> string(). 32 | epoch_to_datetime_string(Epoch) when is_integer(Epoch) -> 33 | DT = erlang:posixtime_to_universaltime(Epoch div 1000), 34 | {{Y,Mo,D}, {H,M,S}} = erlang:universaltime_to_localtime(DT), 35 | fmt("~w-~.2.0w-~.2.0w ~.2.0w:~.2.0w:~.2.0w.~.3.0w", 36 | [Y,Mo,D,H,M,S, Epoch rem 1000]). 37 | 38 | -spec universaltime_to_epoch(calendar:datetime()) -> erlcron:milliseconds(). 39 | universaltime_to_epoch(DT) -> 40 | erlang:universaltime_to_posixtime(DT)*1000. 41 | 42 | -spec localtime_to_epoch(calendar:datetime()) -> erlcron:milliseconds(). 43 | localtime_to_epoch(DT) -> 44 | erlang:universaltime_to_posixtime(erlang:localtime_to_universaltime(DT))*1000. 45 | 46 | 47 | fmt(Fmt, Args) -> 48 | lists:flatten(io_lib:format(Fmt, Args)). 49 | -------------------------------------------------------------------------------- /src/ecrn_sup.erl: -------------------------------------------------------------------------------- 1 | %%% @copyright Erlware, LLC. All Rights Reserved. 2 | %%% 3 | %%% This file is provided to you under the BSD License; you may not use 4 | %%% this file except in compliance with the License. 5 | -module(ecrn_sup). 6 | 7 | -behaviour(supervisor). 8 | 9 | %% API 10 | -export([start_link/0]). 11 | 12 | %% Supervisor callbacks 13 | -export([init/1]). 14 | 15 | -define(SERVER, ?MODULE). 16 | 17 | %%%=================================================================== 18 | %%% API functions 19 | %%%=================================================================== 20 | 21 | -spec start_link() -> {ok, Pid::term()} | ignore | {error, Error::term()}. 22 | start_link() -> 23 | supervisor:start_link({local, ?SERVER}, ?MODULE, []). 24 | 25 | %%%=================================================================== 26 | %%% Supervisor callbacks 27 | %%%=================================================================== 28 | 29 | %% @private 30 | init([]) -> 31 | SupFlags = #{ 32 | strategy => one_for_one, 33 | intensity => application:get_env(erlcron, sup_intensity, 3), 34 | period => application:get_env(erlcron, sup_period, 10) 35 | }, 36 | 37 | ChildSup = #{id => ecrn_cron_sup, 38 | start => {ecrn_cron_sup, start_link, []}, 39 | restart => permanent, 40 | shutdown => 1000, 41 | type => supervisor, 42 | modules => [ecrn_cron_sup]}, 43 | 44 | RegServer = #{id => ecrn_reg, 45 | start => {ecrn_reg, start_link, []}, 46 | restart => permanent, 47 | shutdown => 1000, 48 | type => worker, 49 | modules => [ecrn_reg]}, 50 | 51 | CtrlServer = #{id => ecrn_control, 52 | start => {ecrn_control, start_link, []}, 53 | restart => permanent, 54 | shutdown => 1000, 55 | type => worker, 56 | modules => [ecrn_control]}, 57 | 58 | {ok, {SupFlags, [ChildSup, RegServer, CtrlServer]}}. 59 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Copyright Erlware, LLC. All Rights Reserved. 2 | # 3 | # This file is provided to you under the BSD License; you may not use 4 | # this file except in compliance with the License. You may obtain a 5 | # copy of the License. 6 | 7 | ERLFLAGS= -pa $(CURDIR)/.eunit -pa $(CURDIR)/ebin -pa $(CURDIR)/deps/*/ebin 8 | 9 | DEPS_PLT=$(CURDIR)/.deps_plt 10 | 11 | # ============================================================================= 12 | # Verify that the programs we need to run are installed on this system 13 | # ============================================================================= 14 | ERL = $(shell which erl) 15 | 16 | ifeq ($(ERL),) 17 | $(error "Erlang not available on this system") 18 | endif 19 | 20 | REBAR=$(shell which rebar3) 21 | 22 | ifeq ($(REBAR),) 23 | $(error "Rebar not available on this system") 24 | endif 25 | 26 | .PHONY: all compile doc clean test dialyzer typer shell distclean pdf \ 27 | get-deps escript clean-common-test-data rebuild 28 | 29 | all: compile dialyzer test 30 | 31 | # ============================================================================= 32 | # Rules to build the system 33 | # ============================================================================= 34 | 35 | get-deps: 36 | $(REBAR) get-deps 37 | $(REBAR) compile 38 | 39 | compile: 40 | $(REBAR) compile 41 | 42 | doc: 43 | $(REBAR) ex_doc 44 | 45 | eunit: compile clean-common-test-data 46 | $(REBAR) eunit 47 | 48 | ct: compile clean-common-test-data 49 | $(REBAR) ct 50 | 51 | test: compile eunit 52 | 53 | $(DEPS_PLT): 54 | @echo Building local plt at $(DEPS_PLT) 55 | @echo 56 | dialyzer --output_plt $(DEPS_PLT) --build_plt \ 57 | --apps erts kernel stdlib 58 | 59 | dialyzer: $(DEPS_PLT) 60 | $(REBAR) $@ 61 | 62 | typer: 63 | typer --plt $(DEPS_PLT) -I include -r ./src 64 | 65 | shell: get-deps compile 66 | # You often want *rebuilt* rebar tests to be available to the 67 | # shell you have to call eunit (to get the tests 68 | # rebuilt). However, eunit runs the tests, which probably 69 | # fails (that's probably why You want them in the shell). This 70 | # runs eunit but tells make to ignore the result. 71 | - @$(REBAR) eunit 72 | @$(ERL) $(ERLFLAGS) 73 | 74 | pdf: 75 | pandoc README.md -o README.pdf 76 | 77 | clean-common-test-data: 78 | # We have to do this because of the unique way we generate test 79 | # data. Without this rebar eunit gets very confused 80 | @rm -rf $(CURDIR)/test/*_SUITE_data 81 | 82 | clean: clean-common-test-data 83 | - rm -rf $(CURDIR)/test/*.beam 84 | - rm -rf $(CURDIR)/{.eunit,_build,logs} 85 | - rm -fr TEST-* 86 | $(REBAR) clean 87 | 88 | distclean: clean 89 | - rm -rf $(DEPS_PLT) 90 | - rm -rvf $(CURDIR)/deps/* 91 | 92 | rebuild: distclean get-deps compile dialyzer test 93 | -------------------------------------------------------------------------------- /src/ecrn_app.erl: -------------------------------------------------------------------------------- 1 | %%% @copyright Erlware, LLC. All Rights Reserved. 2 | %%% 3 | %%% This file is provided to you under the BSD License; you may not use 4 | %%% this file except in compliance with the License. 5 | %%%---------------------------------------------------------------- 6 | %%% @doc 7 | %%% erlcron app system 8 | -module(ecrn_app). 9 | 10 | -behaviour(application). 11 | 12 | %% API 13 | -export([manual_start/0, manual_stop/0]). 14 | 15 | %% Application callbacks 16 | -export([start/2, stop/1]). 17 | 18 | -include("erlcron.hrl"). 19 | 20 | %%%=================================================================== 21 | %%% API 22 | %%%=================================================================== 23 | 24 | %% @doc 25 | %% start up the app and all the dependent apps. 26 | manual_start() -> 27 | %application:start(crypto), 28 | application:start(eunit), 29 | %application:start(sasl), 30 | application:start(erlcron). 31 | 32 | %% @doc 33 | %% stop the app manually 34 | manual_stop() -> 35 | application:stop(erlcron). 36 | 37 | %%%=================================================================== 38 | %%% Application callbacks 39 | %%%=================================================================== 40 | 41 | %% @private 42 | start(_StartType, _StartArgs) -> 43 | case ecrn_sup:start_link() of 44 | {ok, Pid} -> 45 | {ok, H} = inet:gethostname(), 46 | Def = application:get_env(erlcron, defaults, #{}), 47 | is_map(Def) orelse 48 | erlang:error("erlcron/defaults config must be a map!"), 49 | ?LOG_INFO("CRON: started on host ~p using defaults: ~1024p", [H, Def]), 50 | setup(Def), 51 | {ok, Pid}; 52 | Error -> 53 | Error 54 | end. 55 | 56 | %% @private 57 | stop(_State) -> 58 | ok. 59 | 60 | setup(Def) -> 61 | case application:get_env(erlcron, crontab) of 62 | {ok, Crontab} -> 63 | lists:foreach(fun(CronJob) -> 64 | Res = erlcron:cron(CronJob, Def), 65 | Res2 = if is_reference(Res) -> io_lib:format(": ~p", [Res]); true -> [] end, 66 | ?LOG_INFO("CRON: adding job ~1024p~s", [CronJob, Res2]), 67 | case Res of 68 | already_started -> 69 | erlang:error({duplicate_job_reference, CronJob}); 70 | ignored -> 71 | ok; 72 | Ref when is_reference(Ref); is_atom(Ref); is_binary(Ref) -> 73 | ok; 74 | {error, Reason} -> 75 | erlang:error({failed_to_add_cron_job, CronJob, Reason}) 76 | end 77 | end, Crontab); 78 | undefined -> 79 | ok 80 | end. 81 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c)2010 eCD Market 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions 5 | are met: 6 | 7 | Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | 10 | Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in 12 | the documentation and/or other materials provided with the 13 | distribution. 14 | 15 | Neither the name of Cat's Eye Technologies nor the names of its 16 | contributors may be used to endorse or promote products derived 17 | from this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND 20 | CONTRIBUTORS ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, 21 | INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 22 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE 24 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, 25 | OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, 27 | OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 28 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 29 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY 30 | OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 31 | POSSIBILITY OF SUCH DAMAGE. 32 | 33 | 34 | 35 | Some code originally copyrighted -> 36 | 37 | Copyright (c)2002 Cat's Eye Technologies. All rights reserved. 38 | 39 | Redistribution and use in source and binary forms, with or without 40 | modification, are permitted provided that the following conditions 41 | are met: 42 | 43 | Redistributions of source code must retain the above copyright 44 | notice, this list of conditions and the following disclaimer. 45 | 46 | Redistributions in binary form must reproduce the above copyright 47 | notice, this list of conditions and the following disclaimer in 48 | the documentation and/or other materials provided with the 49 | distribution. 50 | 51 | Neither the name of Cat's Eye Technologies nor the names of its 52 | contributors may be used to endorse or promote products derived 53 | from this software without specific prior written permission. 54 | 55 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND 56 | CONTRIBUTORS ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, 57 | INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 58 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 59 | DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE 60 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, 61 | OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 62 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, 63 | OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 64 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 65 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY 66 | OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 67 | POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /test/ecrn_startup_test.erl: -------------------------------------------------------------------------------- 1 | %%% @copyright Erlware, LLC. All Rights Reserved. 2 | %%% vim:ts=4:ts=4:et 3 | %%% 4 | %%% This file is provided to you under the BSD License; you may not use 5 | %%% this file except in compliance with the License. 6 | -module(ecrn_startup_test). 7 | -compile(export_all). 8 | -compile(nowarn_export_all). 9 | 10 | -include_lib("eunit/include/eunit.hrl"). 11 | 12 | -define(FuncTest(A), {atom_to_list(A), fun A/0}). 13 | 14 | disable_sasl_logger() -> 15 | logger:add_handler_filter(default, ?MODULE, {fun(_,_) -> stop end, nostate}). 16 | 17 | enable_sasl_logger() -> 18 | logger:remove_handler_filter(default, ?MODULE). 19 | 20 | %%%=================================================================== 21 | %%% Types 22 | %%%=================================================================== 23 | cron_test_() -> 24 | {setup, 25 | fun() -> 26 | Ref = make_ref(), 27 | application:load(erlcron), 28 | application:set_env(erlcron, sup_intensity, 0), 29 | application:set_env(erlcron, sup_period, 1), 30 | application:set_env(erlcron, crontab, [ 31 | {{daily, {1, 0, pm}}, {erlang, system_time, []}, #{id => one}}, 32 | {{daily, {2, 0, pm}}, fun() -> erlang:system_time() end, #{id => <<"two">>}}, 33 | {{daily, {3, 0, pm}}, fun(_JobRef, _Now) -> erlang:system_time() end, #{id => Ref}}, 34 | #{id => four, interval => {daily, {1, 0, pm}}, execute => {erlang, system_time, []}} 35 | ]), 36 | disable_sasl_logger(), 37 | application:start(erlcron), 38 | Ref 39 | end, 40 | fun(_) -> 41 | application:stop(erlcron), 42 | enable_sasl_logger() 43 | end, 44 | {with, [fun(Ref) -> check_startup_jobs(Ref) end]} 45 | }. 46 | 47 | check_startup_jobs(Ref) -> 48 | ?assertMatch([_, _, _, _], ecrn_cron_sup:all_jobs()), 49 | ?assertEqual([four, one, Ref, <<"two">>], ecrn_reg:get_all_refs()), 50 | ?assert(is_pid(ecrn_reg:get(one))), 51 | ?assert(is_pid(ecrn_reg:get(<<"two">>))), 52 | ?assert(is_pid(ecrn_reg:get(Ref))), 53 | ?assert(is_pid(ecrn_reg:get(four))). 54 | 55 | cron_bad_job_spec_test_() -> 56 | {setup, 57 | fun() -> 58 | application:set_env(sasl, sasl_error_logger, false), 59 | application:load(erlcron), 60 | application:set_env(erlcron, sup_intensity, 0), 61 | application:set_env(erlcron, sup_period, 1), 62 | disable_sasl_logger() 63 | end, 64 | fun(_) -> 65 | enable_sasl_logger() 66 | end, 67 | [ 68 | ?_assertMatch( 69 | {error, 70 | {bad_return, {{ecrn_app,start,[normal,[]]}, 71 | {'EXIT', {{module_not_loaded,one, {'$$$bad_module',system_time,[]}, nofile}, [_|_]}}}}}, 72 | begin 73 | application:set_env(erlcron, crontab, [ 74 | {{daily, {1, 0, pm}}, {'$$$bad_module', system_time, []}, #{id => one}} 75 | ]), 76 | application:start(erlcron) 77 | end), 78 | ?_assertMatch( 79 | {error, 80 | {bad_return, 81 | {{ecrn_app,start,[normal,[]]}, 82 | {'EXIT', 83 | {{wrong_arity_of_job_task, one, "erlang:system_time1000/0"}, 84 | [_|_]}}}}}, 85 | begin 86 | application:set_env(erlcron, crontab, [ 87 | {{daily, {1, 0, pm}}, {erlang, system_time1000, []}, #{id => one}} 88 | ]), 89 | application:start(erlcron) 90 | end), 91 | ?_assertMatch( 92 | {error, 93 | {bad_return, 94 | {{ecrn_app,start,[normal,[]]}, 95 | {'EXIT', 96 | {{wrong_arity_of_job_task, one, "erlang:system_time/3"}, 97 | [_|_]}}}}}, 98 | begin 99 | application:set_env(erlcron, crontab, [ 100 | {{daily, {1, 0, pm}}, {erlang, system_time, [1,2,3]}, #{id => one}} 101 | ]), 102 | application:start(erlcron) 103 | end), 104 | ?_assertMatch( 105 | {error, 106 | {bad_return, 107 | {{ecrn_app,start,[normal,[]]}, 108 | {'EXIT', 109 | {{wrong_arity_of_job_task, one, "erlang:system_time123/[0,2]"}, 110 | [_|_]}}}}}, 111 | begin 112 | application:set_env(erlcron, crontab, [ 113 | {{daily, {1, 0, pm}}, {erlang, system_time123}, #{id => one}} 114 | ]), 115 | application:start(erlcron) 116 | end), 117 | ?_assertMatch( 118 | ignored, 119 | erlcron:cron({{daily, {1, 0, pm}}, {'$$$bad_module', system_time, []}, 120 | #{id => ignore_host, hostnames => [<<"$$somehost">>]}}) 121 | ), 122 | ?_assertMatch( 123 | ok, 124 | begin 125 | application:set_env(erlcron, crontab, [ 126 | {{daily, {1, 0, pm}}, {'$$$bad_module', system_time, []}, 127 | #{id => ignore_host, hostnames => [<<"$$somehost">>]}} 128 | ]), 129 | application:start(erlcron) 130 | end) 131 | ] 132 | }. 133 | -------------------------------------------------------------------------------- /src/ecrn_control.erl: -------------------------------------------------------------------------------- 1 | %%% @copyright Erlware, LLC. All Rights Reserved. 2 | %%% 3 | %%% This file is provided to you under the BSD License; you may not use 4 | %%% this file except in compliance with the License. 5 | %%%------------------------------------------------------------------- 6 | %%% @doc 7 | %%% Provides testing/fast forward control for the system 8 | -module(ecrn_control). 9 | 10 | -behaviour(gen_server). 11 | 12 | %% API 13 | -export([start_link/0, 14 | cancel/1, 15 | datetime/0, 16 | datetime/1, 17 | ref_datetime/0, 18 | ref_datetime/1, 19 | set_datetime/1, 20 | set_datetime/2, 21 | reset_datetime/0, 22 | multi_set_datetime/2]). 23 | 24 | %% gen_server callbacks 25 | -export([init/1, handle_call/3, handle_cast/2, handle_info/2, 26 | terminate/2, code_change/3]). 27 | 28 | -define(SERVER, ?MODULE). 29 | 30 | -include("internal.hrl"). 31 | 32 | -record(state, {ref_time :: calendar:datetime(), 33 | epoch_at_ref :: erlcron:seconds()}). 34 | 35 | %%%=================================================================== 36 | %%% API 37 | %%%=================================================================== 38 | 39 | -spec start_link() -> {ok, pid()} | ignore | {error, Error::term()}. 40 | start_link() -> 41 | gen_server:start_link({local, ?SERVER}, ?MODULE, [], []). 42 | 43 | -spec cancel(erlcron:job_ref()) -> boolean(). 44 | cancel(AlarmRef) -> 45 | ecrn_reg:cancel(AlarmRef). 46 | 47 | %% @doc 48 | %% Returns current datetime with reference adjustment in universal time zone. 49 | -spec datetime() -> {calendar:datetime(), erlcron:milliseconds()}. 50 | datetime() -> 51 | gen_server:call(?SERVER, datetime). 52 | 53 | %% @doc 54 | %% Returns current datetime with reference adjustment in universal/local time zone. 55 | -spec datetime(local|universal) -> {calendar:datetime(), erlcron:milliseconds()}. 56 | datetime(universal) -> 57 | datetime(); 58 | datetime(local) -> 59 | {DT, Epoch} = datetime(), 60 | {erlang:universaltime_to_localtime(DT), Epoch}. 61 | 62 | %% @doc Returns reference datetime in universal time zone. 63 | -spec ref_datetime() -> {calendar:datetime(), erlcron:milliseconds()}. 64 | ref_datetime() -> 65 | gen_server:call(?SERVER, ref_datetime). 66 | 67 | %% @doc Returns reference datetime in universal or local time zone. 68 | -spec ref_datetime(local|universal) -> {calendar:datetime(), erlcron:milliseconds()}. 69 | ref_datetime(universal) -> 70 | ref_datetime(); 71 | ref_datetime(local) -> 72 | {DT, Epoch} = ref_datetime(), 73 | {erlang:universaltime_to_localtime(DT), Epoch}. 74 | 75 | %% @doc sets the date-time for the erlcron 76 | -spec set_datetime(calendar:datetime()) -> ok | {error, term()}. 77 | set_datetime(DateTime) -> 78 | set_datetime(DateTime, local). 79 | 80 | -spec set_datetime(calendar:datetime(), local|universal) -> ok | {error, term()}. 81 | set_datetime(DT={_,_}, local) -> 82 | gen_server:call(?SERVER, {set_datetime, erlang:localtime_to_universaltime(DT)}, infinity); 83 | set_datetime(DateTime={_,_}, universal) -> 84 | gen_server:call(?SERVER, {set_datetime, DateTime}, infinity). 85 | 86 | %% @doc Reset reference datetime to current epoch datetime 87 | -spec reset_datetime() -> ok | {error, term()}. 88 | reset_datetime() -> 89 | gen_server:call(?SERVER, reset_datetime, infinity). 90 | 91 | %% @doc sets the date-time with the erlcron on all nodes 92 | -spec multi_set_datetime([node()], calendar:datetime()) -> {Replies, BadNodes} when 93 | Replies :: [{node(), ok | {error, term()}}], 94 | BadNodes :: [node()]. 95 | multi_set_datetime(Nodes, DateTime={_,_}) -> 96 | gen_server:multi_call(Nodes, ?SERVER, {set_datetime, DateTime}). 97 | 98 | %%%=================================================================== 99 | %%% gen_server callbacks 100 | %%%=================================================================== 101 | 102 | %% @private 103 | init([]) -> 104 | DateTime = erlang:universaltime(), 105 | {ok, #state{ref_time=DateTime, 106 | epoch_at_ref=ecrn_util:epoch_milliseconds()}}. 107 | 108 | %% @private 109 | handle_call(datetime, _From, State = #state{ref_time = DateTime, 110 | epoch_at_ref = Actual}) -> 111 | DT = erlang:universaltime_to_posixtime(DateTime), 112 | Now = ecrn_util:epoch_milliseconds(), 113 | Diff = Now - Actual, 114 | DiffS = to_seconds(Diff), 115 | RefNow = DT + DiffS, 116 | Msecs = Diff - DiffS*1000, 117 | NowDT = erlang:posixtime_to_universaltime(RefNow), 118 | {reply, {NowDT, RefNow*1000 + Msecs}, State}; 119 | handle_call(ref_datetime, _From, State = #state{ref_time = DateTime, 120 | epoch_at_ref = Actual}) -> 121 | {reply, {DateTime, Actual}, State}; 122 | handle_call({set_datetime, DateTime}, _From, State) -> 123 | NewState = State#state{ref_time=DateTime, 124 | epoch_at_ref=ecrn_util:epoch_milliseconds()}, 125 | {reply, call_all(NewState), NewState}; 126 | 127 | handle_call(reset_datetime, _From, State) -> 128 | Now = ecrn_util:epoch_milliseconds(), 129 | DateTime = erlang:posixtime_to_universaltime(to_seconds(Now)), 130 | NewState = State#state{ref_time=DateTime, epoch_at_ref=Now}, 131 | {reply, call_all(NewState), NewState}. 132 | 133 | %% @private 134 | handle_cast(_Msg, State) -> 135 | {noreply, State}. 136 | 137 | %% @private 138 | handle_info(_Info, State) -> 139 | {noreply, State}. 140 | 141 | %% @private 142 | terminate(_Reason, _State) -> 143 | ok. 144 | 145 | %% @private 146 | code_change(_OldVsn, State, _Extra) -> 147 | {ok, State}. 148 | 149 | %%%=================================================================== 150 | %%% Internal functions 151 | %%%=================================================================== 152 | %notify_all(#state{ref_time=DateTime, epoch_at_ref=Now}=State) -> 153 | % [ecrn_agent:set_datetime(P, DateTime, Now, universal) || P <- ecrn_reg:get_all_pids()], 154 | % State. 155 | 156 | call_all(#state{ref_time=DateTime, epoch_at_ref=Now}) -> 157 | Res = lists:foldl(fun(P, A) -> 158 | case ecrn_agent:set_datetime(P, DateTime, Now, universal) of 159 | ok -> 160 | A; 161 | {error, Err} -> 162 | Ref = try 163 | ecrn_reg:get_refs(P) 164 | catch _:_ -> 165 | [] 166 | end, 167 | [{Ref, P, Err} | A] 168 | end 169 | end, [], ecrn_reg:get_all_pids()), 170 | case Res of 171 | [] -> ok; 172 | _ -> {error, {failed_to_set_time, Res}} 173 | end. 174 | 175 | to_seconds(MilliSec) -> 176 | MilliSec div 1000. 177 | -------------------------------------------------------------------------------- /src/ecrn_cron_sup.erl: -------------------------------------------------------------------------------- 1 | %%% @copyright Erlware, LLC. All Rights Reserved. 2 | %%% 3 | %%% This file is provided to you under the BSD License; you may not use 4 | %%% this file except in compliance with the License. 5 | %%%------------------------------------------------------------------- 6 | %%% @doc 7 | %%% Simple one for one supervisor for ecd_chron jobs. 8 | -module(ecrn_cron_sup). 9 | 10 | -behaviour(supervisor). 11 | 12 | %% API 13 | -export([start_link/0, 14 | add_job/2, 15 | add_job/3, 16 | all_jobs/0, 17 | terminate/1]). 18 | 19 | %% Supervisor callbacks 20 | -export([init/1]). 21 | 22 | -define(SERVER, ?MODULE). 23 | 24 | %%%=================================================================== 25 | %%% API functions 26 | %%%=================================================================== 27 | 28 | -spec start_link() -> {ok, pid()} | ignore | {error, Error::term()}. 29 | start_link() -> 30 | supervisor:start_link({local, ?SERVER}, ?MODULE, []). 31 | 32 | %% @doc 33 | %% Add a cron job to be supervised 34 | -spec add_job(erlcron:job_ref(), erlcron:job()) -> erlcron:job_ref(). 35 | add_job(JobRef, Job) -> 36 | add_job(JobRef, Job, #{}). 37 | 38 | %% @doc 39 | %% Add a cron job to be supervised 40 | -spec add_job(erlcron:job_ref(), erlcron:job(), erlcron:cron_opts()) -> 41 | erlcron:job_ref() | ignored | already_started | {error, term()}. 42 | add_job(JobRef, Job = #{}, CronOpts) when is_map(CronOpts) -> 43 | {JobSpec, JobOpts} = parse_job(Job), 44 | add_job2(JobRef, JobSpec, check_opts(JobRef, maps:merge(CronOpts, JobOpts))); 45 | add_job(JobRef, Job = {_, _Task}, CronOpts) when is_map(CronOpts) -> 46 | add_job2(JobRef, Job, check_opts(JobRef, CronOpts)); 47 | add_job(JobRef, {When, Task, JobOpts}, CronOpts) when is_map(JobOpts) -> 48 | add_job2(JobRef, {When, Task}, check_opts(JobRef, maps:merge(CronOpts, JobOpts))). 49 | 50 | add_job2(JobRef, Job = {_, Task}, Opts) -> 51 | case check_host(Opts) of 52 | true -> 53 | check_task(JobRef, Task), 54 | case supervisor:start_child(?SERVER, [JobRef, Job, Opts]) of 55 | {ok, _} -> JobRef; 56 | {error, {already_started, _}} -> already_started; 57 | Other -> Other 58 | end; 59 | false -> 60 | ignored 61 | end. 62 | 63 | get_opt(Opt, Map) -> 64 | case maps:take(Opt, Map) of 65 | {V, Map1} -> {V, Map1}; 66 | error -> erlang:error({missing_job_option, Opt, Map}) 67 | end. 68 | 69 | parse_job(Job) -> 70 | {When, Opts1} = get_opt(interval, Job), 71 | {Fun, Opts2} = get_opt(execute, Opts1), 72 | {{When, Fun}, Opts2}. 73 | 74 | %% @doc 75 | %% Get a list of all active jobs 76 | -spec all_jobs() -> [pid()]. 77 | all_jobs() -> 78 | [P || {_,P,_,_} <- supervisor:which_children(?SERVER)]. 79 | 80 | %% @doc 81 | %% Terminate a job 82 | terminate(Pid) when is_pid(Pid) -> 83 | supervisor:terminate_child(?SERVER, Pid). 84 | 85 | %%%=================================================================== 86 | %%% Supervisor callbacks 87 | %%%=================================================================== 88 | 89 | %% @private 90 | init([]) -> 91 | SupFlags = #{ 92 | strategy => simple_one_for_one, 93 | intensity => application:get_env(erlcron, sup_job_intensity, 3), 94 | period => application:get_env(erlcron, sup_job_period, 10) 95 | }, 96 | 97 | AChild = #{id => ecrn_agent, 98 | start => {ecrn_agent, start_link, []}, 99 | restart => transient, 100 | shutdown => brutal_kill, 101 | type => worker, 102 | modules => [ecrn_agent]}, 103 | 104 | {ok, {SupFlags, [AChild]}}. 105 | 106 | %%%=================================================================== 107 | %%% Internal functions 108 | %%%=================================================================== 109 | check_opts(JobRef, Map) -> 110 | maps:foreach(fun 111 | (hostnames, L) when is_list(L) -> 112 | ok; 113 | (on_job_start, MF) when tuple_size(MF)==2; is_function(MF, 1) -> 114 | ok; 115 | (on_job_end, MF) when tuple_size(MF)==2; is_function(MF, 2) -> 116 | ok; 117 | (id, ID) when is_atom(ID); is_binary(ID); is_reference(ID) -> 118 | ok; 119 | (K, V) -> 120 | Info = 121 | if is_function(V) -> 122 | [Name, Arity, Mod, Env0] = 123 | [element(2, erlang:fun_info(V, I)) || I <- [name, arity, module, env]], 124 | Fun = lists:flatten(io_lib:format("~w/~w", [Name, Arity])), 125 | case Env0 of 126 | [T|_] when is_tuple(T) -> 127 | {Mod, element(1,T), Fun}; %% {Module, {Line, Pos}, Fun} 128 | _ -> 129 | {Mod, Fun} 130 | end; 131 | true -> 132 | V 133 | end, 134 | erlang:error({invalid_option_value, JobRef, {K, Info}}) 135 | end, Map), 136 | Map. 137 | 138 | check_host(Opts) -> 139 | case maps:find(hostnames, Opts) of 140 | {ok, Hosts} when is_list(Hosts) -> 141 | {ok, Host} = inet:gethostname(), 142 | lists:member(Host, [to_list(H) || H <- Hosts]); 143 | error -> 144 | true 145 | end. 146 | 147 | check_task(JobRef, Task) when is_tuple(Task), (tuple_size(Task)==2 orelse tuple_size(Task)==3) -> 148 | M = element(1, Task), 149 | case code:ensure_loaded(M) of 150 | {module, M} -> 151 | ok; 152 | {error, Err1} -> 153 | erlang:error({module_not_loaded, JobRef, Task, Err1}) 154 | end, 155 | check_exists(JobRef, Task); 156 | check_task(_, Task) when is_function(Task, 0) -> 157 | ok; 158 | check_task(_, Task) when is_function(Task, 2) -> 159 | ok; 160 | check_task(JobRef, Task) -> 161 | erlang:error({invalid_job_task, JobRef, Task}). 162 | 163 | check_exists(JobRef, {M,F}) -> 164 | check_exists2(JobRef, {M,F,undefined}); 165 | check_exists(JobRef, {_,_,A} = MFA) when is_list(A) -> 166 | check_exists2(JobRef, MFA). 167 | 168 | check_exists2(JobRef, {M,F,A} = Task) -> 169 | case erlang:module_loaded(M) of 170 | false -> 171 | case code:ensure_loaded(M) of 172 | {module, M} -> 173 | ok; 174 | {error, Err1} -> 175 | erlang:error({module_not_loaded, JobRef, Task, Err1}) 176 | end; 177 | true -> 178 | ok 179 | end, 180 | case A of 181 | undefined -> 182 | check_arity(JobRef, M, F, [0,2]); 183 | _ when is_list(A) -> 184 | check_arity(JobRef, M, F, [length(A)]) 185 | end. 186 | 187 | check_arity(JobRef, M, F, Lengths) -> 188 | {module, M} == code:ensure_loaded(M) 189 | orelse erlang:error({job_task_module_not_loaded, JobRef, M}), 190 | lists:any(fun(Arity) -> erlang:function_exported(M,F,Arity) end, Lengths) 191 | orelse erlang:error({wrong_arity_of_job_task, JobRef, report_arity(M,F,Lengths)}). 192 | 193 | report_arity(M, F, [A]) -> 194 | lists:flatten(io_lib:format("~w:~w/~w", [M, F, A])); 195 | report_arity(M, F, A) when is_list(A) -> 196 | Arities = string:join([integer_to_list(I) || I <- A], ","), 197 | lists:flatten(io_lib:format("~w:~w/[~s]", [M, F, Arities])). 198 | 199 | to_list(H) when is_binary(H) -> binary_to_list(H); 200 | to_list(H) when is_list(H) -> H. 201 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Erlcron 2 | ======= 3 | 4 | [](https://github.com/erlware/erlcron/actions/workflows/ci.yml) 5 | [](https://hex.pm/packages/erlcron) 6 | [](https://hexdocs.pm/erlcron) 7 | 8 | Erlcron provides testable cron like functionality for Erlang 9 | systems, with the ability to arbitrarily set the time and place along 10 | with fastforwarding through tests. See erlcron.erl for more 11 | documentation. 12 | 13 | The syntax of a job description is quite different from crontab. It is 14 | (in this author's opinion) easier to read and is much more in keeping 15 | with the Erlang tradition. It is not quite as expressive as cron but 16 | this can be compensated for by adding multiple jobs. 17 | 18 | No output is logged or mailed to anyone. If you want output to be 19 | logged or mailed, you must explicitly specify that as part of the job. 20 | 21 | This does not poll the system on a minute-by-minute basis like cron 22 | does. Each job is assigned to a single (Erlang) process. The time 23 | until it is to run next is calculated, and the process sleeps for 24 | exactly that long. 25 | 26 | Unlike cron's one-minute resolution, erlcron has a millisecond resolution. 27 | 28 | It does handle Daylight Savings Time changes (but not the cases when the 29 | system clock is altered by small increments, in which case the next 30 | execution of a scheduled task may be imprecise). 31 | 32 | Cron Job Description Examples: 33 | 34 | ```erlang 35 | {{once, {3, 30, pm}, fun() -> io:fwrite("Hello world!~n") end} 36 | 37 | {{once, {3, 30, pm}, fun(JobRef, CurrDateTime) -> io:fwrite("Hello world!~n") end} 38 | 39 | {{once, {3, 30, pm}}, 40 | {io, fwrite, ["Hello, world!~n"]}} 41 | 42 | {{once, {12, 23, 32}}, 43 | {io, fwrite, ["Hello, world!~n"]}} 44 | 45 | {{once, 3600}, 46 | {io, fwrite, ["Hello, world!~n"]}} 47 | 48 | {{daily, {every, {23, sec}, {between, {3, pm}, {3, 30, pm}}}}, 49 | {io, fwrite, ["Hello, world!~n"]}} 50 | 51 | {{daily, {3, 30, pm}}, 52 | fun(_JobRef, _DateTime) -> io:fwrite("It's three thirty~n") end} 53 | 54 | {{daily, [{1, 10, am}, {1, 07, 30, am}]}, 55 | {io, fwrite, ["Bing~n"]}} 56 | 57 | {{weekly, thu, {2, am}}, 58 | {io, fwrite, ["It's 2 Thursday morning~n"]}} 59 | 60 | {{weekly, wed, {2, am}}, 61 | {fun(_JobRef, _DateTime) -> io:fwrite("It's 2 Wednesday morning~n") end} 62 | 63 | {{weekly, [tue,wed], {2, am}}, 64 | {fun(_, Now) -> io:format("Now is ~p~n", [Now]) end} 65 | 66 | {{weekly, fri, {2, am}}, 67 | {io, fwrite, ["It's 2 Friday morning~n"]}} 68 | 69 | {{monthly, 1, {2, am}}, 70 | {io, fwrite, ["First of the month!~n"]}} 71 | 72 | {{monthly, [1, 7, 14], {2, am}}, 73 | {io, fwrite, ["Every 1st, 7th, 14th of the month!~n"]}} 74 | 75 | {{monthly, 0, {2, am}}, 76 | {io, fwrite, ["Last day of the month!~n"]}} 77 | 78 | {{monthly, [0, -1], {2, am}}, 79 | {io, fwrite, ["Last two days of the month!~n"]}} 80 | 81 | {{monthly, 4, {2, am}}, 82 | {io, fwrite, ["Fourth of the month!~n"]}} 83 | 84 | %% Days of month less or equal to zero are subtracted from the end of the month 85 | {{monthly, 0, {2, am}}, 86 | {io, fwrite, ["Last day of the month!~n"]}} 87 | ``` 88 | 89 | Adding a cron to the system: 90 | 91 | ```erlang 92 | Job = {{weekly, thu, {2, am}}, 93 | {io, fwrite, ["It's 2 Thursday morning~n"]}}. 94 | 95 | erlcron:cron(Job). 96 | ``` 97 | 98 | Cron jobs can be given named atom references: 99 | 100 | ```erlang 101 | erlcron:cron(test_job1, {{once, {3,pm}}, {io, fwrite, "It's 3pm"}}). 102 | ``` 103 | 104 | A simple way to add a daily cron: 105 | 106 | erlcron:daily({3, 30, pm}, Fun). 107 | 108 | A simple way to add a job that will be run once in the future. Where 109 | 'once' is the number of seconds until it runs. 110 | 111 | ```erlang 112 | erlcron:at(300, Fun). 113 | ``` 114 | 115 | Cancel a running job. 116 | 117 | ```erlang 118 | erlcron:cancel(JobRef). 119 | ``` 120 | 121 | Get the current reference (universal) date time of the system. 122 | 123 | ```erlang 124 | erlcron:datetime(). 125 | ``` 126 | 127 | Set the current date time of the system. Any tests that need to be run 128 | in the interim will be run as the time rolls forward. 129 | 130 | ```erlang 131 | erlcron:set_datetime(DateTime). 132 | ``` 133 | 134 | Set the current date time of the system on all nodes in the 135 | cluster. Any tests that need to be run in the interim will be run as 136 | the time rolls forward. 137 | 138 | ```erlang 139 | erlcron:multi_set_datetime(DateTime). 140 | ``` 141 | 142 | Set the current date time of the system on a specific set of nodes in 143 | the cluster. Any tests that need to be run in the interim will be run 144 | as the time rolls forward. 145 | 146 | ```erlang 147 | erlcron:multi_set_datetime(Nodes, DateTime). 148 | ``` 149 | 150 | The application cron can be pre-configured through environment variables 151 | in the config file that all applications can load in the Erlang VM start. 152 | The app.config file can be as follow: 153 | 154 | ```erlang 155 | [ 156 | {erlcron, [ 157 | {crontab, [ 158 | {{once, {3, 30, pm}}, {io, fwrite, ["Hello, world!~n"]}}, 159 | 160 | %% A job may be specified to be run only if the current host 161 | %% is in the list of given hostnames. If default crontab 162 | %% options are provided in the `defaults` setting, 163 | %% the job-specific options will override corresponding 164 | %% default options. See erlcron:job_opts() for available 165 | %% options. 166 | {{once, {12, 23, 32}}, {io, fwrite, ["Hello, world!~n"]}, 167 | #{hostnames => ["somehost"]}, 168 | 169 | {{daily, {every, {23, sec}, {between, {3, pm}, {3, 30, pm}}}}, 170 | {io, fwrite, ["Hello, world!~n"]}}, 171 | 172 | %% A job spec can be defined as a map, where the `interval' and 173 | %% `execute' keys are mandatory: 174 | #{id => test_job, interval => {daily, {1, 0, pm}}, 175 | execute => {io, fwrite, ["Hello, world!~n"]}}, 176 | 177 | %% If defined as a map, the map can contain any `erlcron:job_opts()' 178 | %% options: 179 | #{id => another_job, interval => {daily, {1, 0, pm}}, 180 | execute => {io, fwrite, ["Hello, world!~n"]}, 181 | hostnames => ["myhost"]} 182 | ]}, 183 | 184 | %% Instead of specifying individual options for each job, you can 185 | %% define default options here. 186 | {defaults, #{ 187 | %% Limit jobs to run only on the given list of hosts 188 | hostnames => ["myhost"], 189 | 190 | %% Function `fun((Ref) -> ignore | any())` to call before a job is started. 191 | %% If the function returns `ignore`, the job will not be executed, and the 192 | %% `on_job_end` callback will not be executed. 193 | on_job_start => {some_module, function}, 194 | 195 | %% Function `fun((Ref, Status :: {ok, Result}|{error, {Reason, StackTrace}}) -> ok)` 196 | %% to call after a job has ended 197 | on_job_end => {some_module, function} 198 | }} 199 | ]} 200 | ]. 201 | ``` 202 | 203 | So, when the app will be started, all configurations will be loaded. 204 | 205 | Note that the limitation is that in the config file is impossible load an 206 | anonymous function (or lambda function) so, you only can use {M,F,A} format. 207 | 208 | If an `on_job_start` or `on_job_end` functions are provided for any job or as 209 | default settings, it's the responsibility of a developer to make sure that 210 | those functions handle exceptions, since the failure in those functions will 211 | cause the supervisor to restart the job up until the supervisor reaches its 212 | maximum restart intensity. 213 | -------------------------------------------------------------------------------- /src/ecrn_reg.erl: -------------------------------------------------------------------------------- 1 | %%% @copyright Erlware, LLC. All Rights Reserved. 2 | %%% 3 | %%% This file is provided to you under the BSD License; you may not use 4 | %%% this file except in compliance with the License. 5 | %%%------------------------------------------------------------------- 6 | %%% @doc 7 | %%% This provides simple pid registration for the server. 8 | -module(ecrn_reg). 9 | 10 | -behaviour(gen_server). 11 | 12 | %% API 13 | -export([start_link/0, 14 | register/2, 15 | unregister/1, 16 | get/1, 17 | get_refs/1, 18 | cancel/1, 19 | stop/0, 20 | get_all/0, 21 | get_all_pids/0, 22 | get_all_refs/0]). 23 | 24 | %% gen_server callbacks 25 | -export([init/1, handle_call/3, handle_cast/2, handle_info/2, 26 | terminate/2, code_change/3]). 27 | 28 | -define(SERVER, ?MODULE). 29 | 30 | -record(state, {pid2ref, ref2pid}). 31 | 32 | %%%=================================================================== 33 | %%% API 34 | %%%=================================================================== 35 | 36 | -spec start_link() -> {ok, Pid::pid()} | ignore | {error, Error::term()}. 37 | start_link() -> 38 | gen_server:start_link({local, ?SERVER}, ?MODULE, [], []). 39 | 40 | %% @doc 41 | %% Register an arbitrary value with the system, under a set of keys 42 | -spec register(erlcron:job_ref(), term()) -> boolean(). 43 | register(Key, Pid) when (is_atom(Key) orelse is_reference(Key) orelse is_binary(Key)), is_pid(Pid) -> 44 | gen_server:call(?SERVER, {register, Key, Pid}). 45 | 46 | %% @doc 47 | %% Remove the value registered under a que or set of keys 48 | -spec unregister(erlcron:job_ref()) -> ok. 49 | unregister(Key) when is_atom(Key); is_reference(Key); is_binary(Key) -> 50 | gen_server:cast(?SERVER, {unregister, Key}). 51 | 52 | %% @doc 53 | %% Get a pid by reference key. 54 | -spec get(erlcron:job_ref()) -> pid() | undefined. 55 | get(Key) when is_atom(Key); is_reference(Key); is_binary(Key) -> 56 | gen_server:call(?SERVER, {get, Key}). 57 | 58 | %% @doc 59 | %% Get job refs associated with the pid 60 | -spec get_refs(pid()) -> [erlcron:job_ref()]. 61 | get_refs(Pid) when is_pid(Pid) -> 62 | lists:sort(gen_server:call(?SERVER, {get_refs, Pid})). 63 | 64 | %% @doc 65 | %% Cancel all jobs assigned to the given key 66 | -spec cancel(term()) -> boolean(). 67 | cancel(Key) when is_atom(Key); is_reference(Key); is_binary(Key) -> 68 | gen_server:call(?SERVER, {cancel, Key}). 69 | 70 | %% @doc 71 | %% Get all the values. 72 | -spec get_all() -> [{term(), term()}]. 73 | get_all() -> 74 | gen_server:call(?SERVER, get_all). 75 | 76 | %% @doc 77 | %% Get all registered Pids. 78 | -spec get_all_pids() -> [pid()]. 79 | get_all_pids() -> 80 | gen_server:call(?SERVER, get_all_pids). 81 | 82 | %% @doc 83 | %% Get all registered job references. 84 | -spec get_all_refs() -> [erlcron:job_ref()]. 85 | get_all_refs() -> 86 | gen_server:call(?SERVER, get_all_refs). 87 | 88 | %% @doc 89 | %% stop this server 90 | -spec stop() -> ok. 91 | stop() -> 92 | gen_server:call(?SERVER, stop). 93 | 94 | %%%=================================================================== 95 | %%% gen_server callbacks 96 | %%%=================================================================== 97 | 98 | %% @private 99 | init([]) -> 100 | {ok, #state{pid2ref=#{}, ref2pid=#{}}}. 101 | 102 | %% @private 103 | handle_call({register, Ref, Pid}, _From, #state{pid2ref=M1, ref2pid=M2}=State) -> 104 | Ref1 = monitor(process, Pid), 105 | case maps:find(Pid, M1) of 106 | {ok, Refs} when is_list(Refs) -> 107 | demonitor(Ref1, [flush]), 108 | case lists:member(Ref, Refs) of 109 | true -> 110 | {reply, false, State}; 111 | false -> 112 | {reply, true, State#state{ref2pid=M2#{Ref=>Pid}, pid2ref=M1#{Pid=>[Ref|Refs]}}} 113 | end; 114 | error -> 115 | {reply, true, State#state{ref2pid=M2#{Ref=>Pid}, pid2ref=M1#{Pid=>[Ref]}}} 116 | end; 117 | handle_call({get, Key}, _From, State) -> 118 | case get_for_key(Key, State) of 119 | {ok, V} -> {reply, V, State}; 120 | undefined -> {reply, undefined, State} 121 | end; 122 | handle_call({get_refs, Pid}, _From, State) -> 123 | case get_for_key(Pid, State) of 124 | {ok, V} -> {reply, V, State}; 125 | undefined -> {reply, [], State} 126 | end; 127 | handle_call({cancel, Pid}, _From, State) -> 128 | {Found, State1} = find_and_remove(Pid, State, fun(P) -> ecrn_agent:cancel(P) end), 129 | {reply, Found, State1}; 130 | handle_call(stop, _From, State) -> 131 | {stop, normal, ok, State}; 132 | handle_call(get_all, _From, State = #state{ref2pid=Map}) -> 133 | {reply, maps:to_list(Map), State}; 134 | handle_call(get_all_refs, _From, State = #state{ref2pid=Map}) -> 135 | {reply, maps:keys(Map), State}; 136 | handle_call(get_all_pids, _From, State = #state{pid2ref=Map}) -> 137 | {reply, maps:keys(Map), State}. 138 | 139 | %% @private 140 | handle_cast({unregister, Key}, State) -> 141 | {_, NewState} = find_and_remove(Key, State, undefined), 142 | {noreply, NewState}; 143 | handle_cast(_Msg, State) -> 144 | {noreply, State}. 145 | 146 | %% @private 147 | handle_info({'DOWN', _Ref, process, Pid, _Info}, State) -> 148 | handle_cast({unregister, Pid}, State); 149 | handle_info(_Info, State) -> 150 | {noreply, State}. 151 | 152 | %% @private 153 | terminate(_Reason, _State) -> 154 | ok. 155 | 156 | %% @private 157 | code_change(_OldVsn, State, _Extra) -> 158 | {ok, State}. 159 | 160 | %%%=================================================================== 161 | %%% Internal functions 162 | %%%=================================================================== 163 | get_for_key(Ref, #state{ref2pid=Map}) when is_reference(Ref); is_atom(Ref); is_binary(Ref) -> 164 | case maps:find(Ref, Map) of 165 | error -> undefined; 166 | OkValue -> OkValue 167 | end; 168 | get_for_key(Pid, #state{pid2ref=Map}) when is_pid(Pid) -> 169 | case maps:find(Pid, Map) of 170 | error -> undefined; 171 | OkValue -> OkValue 172 | end. 173 | 174 | -spec find_and_remove(erlcron:job_ref()|pid(), #state{}, undefined|fun((pid())->ok)) -> 175 | {boolean(), #state{}}. 176 | find_and_remove(Pid, State = #state{ref2pid=M1, pid2ref=M2}, Fun) when is_pid(Pid) -> 177 | case maps:find(Pid, M2) of 178 | {ok, Refs} -> 179 | is_function(Fun, 1) andalso Fun(Pid), 180 | NewM1 = lists:foldl(fun(R, M) -> maps:remove(R, M) end, M1, Refs), 181 | {true, State#state{ref2pid=NewM1, pid2ref=maps:remove(Pid,M2)}}; 182 | error -> 183 | {false, State} 184 | end; 185 | find_and_remove(Ref, S = #state{ref2pid=M1}, Fun) -> 186 | case maps:find(Ref, M1) of 187 | {ok, Pid} -> 188 | is_function(Fun, 1) andalso Fun(Pid), 189 | {true, find_and_remove2(Pid, Ref, S)}; 190 | error -> 191 | {false, S} 192 | end. 193 | 194 | find_and_remove2(Pid, Ref, S = #state{ref2pid=M1, pid2ref=M2}) when is_pid(Pid) -> 195 | case maps:find(Pid, M2) of 196 | {ok, Refs} -> 197 | case lists:delete(Ref, Refs) of 198 | [] -> 199 | S#state{ref2pid=maps:remove(Ref,M1), pid2ref=maps:remove(Pid,M2)}; 200 | L -> 201 | S#state{ref2pid=maps:remove(Ref,M1), pid2ref=M2#{Pid => L}} 202 | end; 203 | error -> 204 | S#state{ref2pid=maps:remove(Ref,M1)} 205 | end. 206 | 207 | %%%=================================================================== 208 | %%% Tests 209 | %%%=================================================================== 210 | 211 | -ifdef(TEST). 212 | -include_lib("eunit/include/eunit.hrl"). 213 | -endif. 214 | 215 | -ifdef(EUNIT). 216 | 217 | generate_test_() -> 218 | {setup, 219 | fun () -> ecrn_reg:start_link() end, 220 | fun (_) -> ecrn_reg:stop() end, 221 | {with, 222 | [fun general_tests/1]}}. 223 | 224 | general_tests(_) -> 225 | Self = self(), 226 | ?assertMatch(true, ecrn_reg:register(a, Self)), 227 | ?assertMatch(Self, ecrn_reg:get(a)), 228 | ecrn_reg:unregister(a), 229 | Ref = make_ref(), 230 | ?assertMatch(undefined, ecrn_reg:get(a)), 231 | ?assertMatch(true, ecrn_reg:register(b, Self)), 232 | ?assertMatch(true, ecrn_reg:register(c, Self)), 233 | ?assertMatch(true, ecrn_reg:register(Ref, Self)), 234 | ?assertMatch(Self, ecrn_reg:get(c)), 235 | ?assertMatch(Self, ecrn_reg:get(b)), 236 | ?assertMatch(Self, ecrn_reg:get(Ref)), 237 | ?assertMatch([b,c,Ref], ecrn_reg:get_refs(Self)), 238 | ?assertMatch(false, ecrn_reg:register(b, Self)), 239 | ?assertMatch(false, ecrn_reg:register(c, Self)), 240 | ?assertMatch(false, ecrn_reg:register(Ref, Self)), 241 | ?assertMatch([b,c,Ref], ecrn_reg:get_all_refs()), 242 | ?assertMatch([Self], ecrn_reg:get_all_pids()), 243 | ecrn_reg:unregister(b), 244 | ?assertMatch([c,Ref], ecrn_reg:get_all_refs()), 245 | ?assertMatch([Self], ecrn_reg:get_all_pids()), 246 | ?assertMatch(true, ecrn_reg:register(<<"d">>, Self)), 247 | ?assertMatch(Self, ecrn_reg:get(<<"d">>)), 248 | ecrn_reg:unregister(<<"d">>), 249 | ?assertMatch(undefined, ecrn_reg:get(<<"d">>)), 250 | ok. 251 | 252 | -endif. 253 | -------------------------------------------------------------------------------- /test/ecrn_test.erl: -------------------------------------------------------------------------------- 1 | %%% @copyright Erlware, LLC. All Rights Reserved. 2 | %%% vim:ts=4:ts=4:et 3 | %%% 4 | %%% This file is provided to you under the BSD License; you may not use 5 | %%% this file except in compliance with the License. 6 | -module(ecrn_test). 7 | -compile(export_all). 8 | -compile(nowarn_export_all). 9 | 10 | -import(ecrn_startup_test, [disable_sasl_logger/0, enable_sasl_logger/0]). 11 | 12 | -include_lib("eunit/include/eunit.hrl"). 13 | 14 | -define(FuncTest(A), {atom_to_list(A), fun A/0}). 15 | 16 | %%%=================================================================== 17 | %%% Types 18 | %%%=================================================================== 19 | cron_test_() -> 20 | {setup, 21 | fun() -> 22 | application:load(erlcron), 23 | application:set_env(erlcron, sup_intensity, 0), 24 | application:set_env(erlcron, sup_period, 1), 25 | application:unset_env(erlcron, crontab), 26 | disable_sasl_logger(), 27 | application:start(erlcron) 28 | end, 29 | fun(_) -> 30 | application:stop(erlcron), 31 | enable_sasl_logger() 32 | end, 33 | [{timeout, 30, [ 34 | ?FuncTest(set_alarm), 35 | ?FuncTest(start_stop_fun1), 36 | ?FuncTest(start_stop_fun2), 37 | ?FuncTest(start_stop_fun3), 38 | ?FuncTest(travel_back_in_time), 39 | ?FuncTest(cancel_alarm), 40 | ?FuncTest(big_time_jump), 41 | ?FuncTest(cron), 42 | ?FuncTest(cron_run_job_on_host), 43 | ?FuncTest(cron_skip_job_on_host), 44 | ?FuncTest(validation) 45 | ]}, 46 | {timeout, 30, [ 47 | ?FuncTest(weekly) 48 | ]}, 49 | {timeout, 30, [ 50 | ?FuncTest(weekly_every) 51 | ]} 52 | ]}. 53 | 54 | set_alarm() -> 55 | erlcron:set_datetime({{2000,1,1},{8,0,0}}), 56 | 57 | Self = self(), 58 | 59 | erlcron:at(test1, {9,0,0}, fun() -> Self ! ack1 end), 60 | erlcron:at(test2, {9,0,1}, fun() -> Self ! ack2 end), 61 | erlcron:daily(test3, {every, {1,s}, {between, {9,0,2}, {9,0,4}}}, 62 | fun() -> Self ! ack3 end), 63 | 64 | erlcron:set_datetime({{2000,1,1},{8,59,59}}), 65 | 66 | %% The alarm should trigger this nearly immediately. 67 | ?assertMatch(1, collect(ack1, 1500, 1)), 68 | 69 | %% The alarm should trigger this 1 second later. 70 | ?assertMatch(1, collect(ack2, 2500, 1)), 71 | 72 | %% The alarm should trigger this 1 second later. 73 | ?assertMatch(3, collect(ack3, 3000, 3)), 74 | 75 | erlcron:cancel(test3). 76 | 77 | cancel_alarm() -> 78 | Day = {2000,1,1}, 79 | erlcron:set_datetime({Day,{8,0,0}}), 80 | AlarmTimeOfDay = {9,0,0}, 81 | 82 | Self = self(), 83 | 84 | Ref = erlcron:at(AlarmTimeOfDay, fun(_, _) -> Self ! ack end), 85 | erlcron:cancel(Ref), 86 | erlcron:set_datetime({Day, AlarmTimeOfDay}), 87 | ?assertMatch(0, collect(ack, 125, 1)). 88 | 89 | start_stop_fun1() -> 90 | Day = {2000,1,1}, 91 | erlcron:set_datetime({Day,{8,0,0}}), 92 | AlarmTimeOfDay = {8,0,1}, 93 | 94 | Self = self(), 95 | Opts = #{on_job_start => fun(Ref) -> Self ! {start, Ref} end, 96 | on_job_end => fun(Ref, Res) -> Self ! {finish, Ref, Res} end}, 97 | Ref1 = erlcron:at(test1, AlarmTimeOfDay, fun(_, _) -> Self ! ack, 1234 end, Opts), 98 | ?assertEqual(test1, Ref1), 99 | ?assertMatch(1, collect({start, test1}, 1500, 1)), 100 | ?assertMatch(1, collect(ack, 125, 1)), 101 | ?assertMatch(1, collect({finish, test1, {ok, 1234}}, 1500, 1)). 102 | 103 | start_stop_fun2() -> 104 | Day = {2000,1,1}, 105 | erlcron:set_datetime({Day,{8,0,0}}), 106 | AlarmTimeOfDay = {8,0,1}, 107 | 108 | Self = self(), 109 | Opts2 = #{on_job_start => fun(Ref) -> Self ! {ignored, Ref}, ignore end, 110 | on_job_end => fun(Ref, Res) -> Self ! {finish, Ref, Res} end}, 111 | Ref2 = erlcron:at(test2, AlarmTimeOfDay, fun(_, _) -> timer:sleep(60000), Self ! ack, 1000 end, Opts2), 112 | 113 | ?assertEqual(test2, Ref2), 114 | ?assertMatch(1, collect({ignored, test2}, 1500, 1)), 115 | ?assertMatch(0, collect({finish, test2, {ok, 1000}}, 125, 1)), 116 | ?assertEqual(undefined, ecrn_reg:get(test2)). 117 | 118 | start_stop_fun3() -> 119 | Day = {2000,1,1}, 120 | erlcron:set_datetime({Day,{8,0,0}}), 121 | AlarmTimeOfDay = {8,0,1}, 122 | 123 | Self = self(), 124 | Opts = #{on_job_start => fun(Ref) -> Self ! {start, Ref} end, 125 | on_job_end => fun(Ref, Res) -> Self ! {finish, Ref, Res} end}, 126 | Len3 = length(lists:seq(1, rand:uniform(10)+2)), 127 | Ref3 = erlcron:at(test3, AlarmTimeOfDay, fun(_, _) -> 1 = Len3 end, Opts), 128 | 129 | ?assertEqual(test3, Ref3), 130 | ?assertMatch(1, collect({start, test3}, 1500, 1)), 131 | ?assertMatch(0, collect(1, 125, 1)), 132 | ?assertMatch({finish, test3, {error,{{badmatch, _}, [_|_]}}}, receive _M -> _M after 1500 -> timeout end). 133 | 134 | %% Time jumps ahead one day so we should see the alarms from both days. 135 | big_time_jump() -> 136 | Day1 = {2000,1,1}, 137 | Day2 = {2000,1,2}, 138 | EpochDateTime = {Day1,{8,0,0}}, 139 | erlcron:set_datetime(EpochDateTime), 140 | Alarm1TimeOfDay = {9,0,0}, 141 | Alarm2TimeOfDay = {9,0,1}, 142 | 143 | Self = self(), 144 | 145 | erlcron:daily(Alarm1TimeOfDay, fun(_, _) -> Self ! ack1 end), 146 | erlcron:daily(Alarm2TimeOfDay, fun(_, _) -> Self ! ack2 end), 147 | erlcron:set_datetime({Day2, {9, 10, 0}}), 148 | ?assertMatch(1, collect(ack1, 1500, 1)), 149 | ?assertMatch(1, collect(ack2, 1500, 1)), 150 | ?assertMatch(1, collect(ack1, 1500, 1)), 151 | ?assertMatch(1, collect(ack2, 1500, 1)). 152 | 153 | travel_back_in_time() -> 154 | Seconds = seconds_now(), 155 | Past = {{2000,1,1},{12,0,0}}, 156 | erlcron:set_datetime(Past, universal), 157 | {ExpectedDateTime, _} = erlcron:datetime(), 158 | ExpectedSeconds = calendar:datetime_to_gregorian_seconds(ExpectedDateTime), 159 | ?assertMatch(true, ExpectedSeconds =< calendar:datetime_to_gregorian_seconds(Past)), 160 | ?assertMatch(true, ExpectedSeconds < Seconds). 161 | 162 | cron() -> 163 | Day1 = {2000,1,1}, 164 | AlarmTimeOfDay = {15,29,59}, 165 | erlcron:set_datetime({Day1, AlarmTimeOfDay}), 166 | 167 | Self = self(), 168 | 169 | Ref1 = make_ref(), 170 | Job1 = {{daily, {3, 30, pm}}, fun(Ref, _) -> Self ! {ack, Ref} end}, 171 | Ref1 = erlcron:cron(Ref1, Job1), 172 | 173 | Job2 = {{daily, {3, 30, pm}}, fun(Ref, _) -> Self ! {ack, Ref} end}, 174 | test2 = erlcron:cron(test2, Job2), 175 | 176 | Job3 = {{daily, {3, 30, pm}}, fun() -> Self ! {ack, test3} end}, 177 | test3 = erlcron:cron(test3, Job3), 178 | 179 | Job4 = {{daily, {3, 30, pm}}, {?MODULE, sample_arity0}, 180 | #{on_job_end => fun(Ref, {ok, undefined}) -> Self ! {ack, Ref} end}}, 181 | test4 = erlcron:cron(test4, Job4), 182 | 183 | Job5 = {{daily, {3, 30, pm}}, {?MODULE, sample_arity2}, 184 | #{on_job_end => fun(_Ref, {ok, Res}) -> Self ! Res end}}, 185 | test5 = erlcron:cron(test5, Job5), 186 | Job6 = {{daily, {3, 30, pm}}, {?MODULE, sample_arityN, [test6, Self]}}, 187 | test6 = erlcron:cron(test6, Job6), 188 | 189 | ?assertMatch(1, collect({ack, Ref1}, 1000, 1)), 190 | ?assertMatch(1, collect({ack, test2}, 1000, 1)), 191 | ?assertMatch(1, collect({ack, test3}, 1000, 1)), 192 | ?assertMatch(1, collect({ack, test4}, 1000, 1)), 193 | ?assertMatch(1, collect({ack, test5}, 1000, 1)), 194 | ?assertMatch(1, collect({ack, test6}, 1000, 1)). 195 | 196 | sample_arity0() -> 197 | undefined. 198 | sample_arity2(Ref, _) -> 199 | {ack, Ref}. 200 | sample_arityN(Ref, Pid) -> 201 | Pid ! {ack, Ref}. 202 | 203 | %% Run job on this host 204 | cron_run_job_on_host() -> 205 | {ok, Host} = inet:gethostname(), 206 | erlcron:set_datetime({{2000, 1, 1}, {12, 59, 59}}), 207 | Self = self(), 208 | Ref = make_ref(), 209 | Job = {{once, {1, 00, pm}}, fun(_,_) -> Self ! Ref end, #{hostnames => [Host]}}, 210 | 211 | ?assert(is_reference(erlcron:cron(Job))), 212 | ?assertMatch(1, collect(Ref, 2500, 1)). 213 | 214 | %% Don't add job when executed on a disallowed host 215 | cron_skip_job_on_host() -> 216 | {ok, Host} = inet:gethostname(), 217 | Self = self(), 218 | Ref = make_ref(), 219 | Job = {{once, {1, 00, pm}}, fun(_,_) -> Self ! Ref end, #{hostnames => [Host ++ "123"]}}, 220 | ?assertEqual(ignored, erlcron:cron(Job)). 221 | 222 | validation() -> 223 | erlcron:set_datetime({{2000,1,1}, {15,0,0}}), 224 | ?assertMatch(ok, ecrn_agent:validate({once, {3, 30, pm}})), 225 | erlcron:set_datetime({{2000,1,1}, {15,31,0}}), 226 | ?assertMatch({error,{specified_time_past_seconds_ago, -60}}, 227 | ecrn_agent:validate({once, {3, 30, pm}})), 228 | 229 | ?assertMatch(ok, ecrn_agent:validate({once, 3600})), 230 | ?assertMatch(ok, ecrn_agent:validate({daily, {every, {0,s}}})), 231 | ?assertMatch(ok, ecrn_agent:validate({daily, {every, {23,s}}})), 232 | ?assertMatch(ok, ecrn_agent:validate({daily, {every, {23,sec}, 233 | {between, {3, pm}, {3, 30, pm}}}})), 234 | ?assertMatch(ok, ecrn_agent:validate({daily, {3, 30, pm}})), 235 | ?assertMatch(ok, ecrn_agent:validate({weekly, thu, {2, am}})), 236 | ?assertMatch(ok, ecrn_agent:validate({weekly, wed, {2, am}})), 237 | ?assertMatch(ok, ecrn_agent:validate({weekly, fri, {every, {5,sec}}})), 238 | ?assertMatch(ok, ecrn_agent:validate({monthly, 1, {2, am}})), 239 | ?assertMatch(ok, ecrn_agent:validate({monthly, 4, {2, am}})), 240 | ?assertMatch({error,{invalid_time,{55,22,am}}}, 241 | ecrn_agent:validate({daily, {55, 22, am}})), 242 | ?assertMatch({error,{invalid_days_in_schedule,{monthly,"A",{55,am}}}}, 243 | ecrn_agent:validate({monthly, 65, {55, am}})). 244 | 245 | weekly() -> 246 | DateF = fun (Offset) -> {2000, 1, 1 + Offset} end, 247 | erlcron:set_datetime({DateF(0), {7,0,0}}), 248 | Self = self(), 249 | erlcron:cron(weekly, {{weekly, [sat, sun], {9,0,0}}, fun() -> Self ! weekly end}), 250 | Pattern = [1, 1, 0, 0, 0, 0, 0, 1], 251 | collect_weekly(DateF, {8, 0, 0}, {10, 0, 0}, Pattern), 252 | erlcron:cancel(weekly). 253 | 254 | weekly_every() -> 255 | DateF = fun (Offset) -> {2000, 1, 1 + Offset} end, 256 | erlcron:set_datetime({DateF(0), {7,0,0}}), 257 | Self = self(), 258 | erlcron:cron(weekly, {{weekly, [sat, mon], 259 | {every, {29, sec}, {between, {9, 0, 0}, {9, 1, 0}}}}, 260 | fun() -> Self ! weekly end}), 261 | Pattern = [3, 0, 3, 0, 0, 0, 0, 3], 262 | collect_weekly(DateF, {8, 0, 0}, {10, 0, 0}, Pattern), 263 | erlcron:cancel(weekly). 264 | 265 | %%%=================================================================== 266 | %%% Internal Functions 267 | %%%=================================================================== 268 | seconds_now() -> 269 | calendar:datetime_to_gregorian_seconds(calendar:universal_time()). 270 | 271 | collect(Msg, Timeout, Count) -> 272 | collect(Msg, Timeout, 0, Count). 273 | collect(_Msg, _Timeout, Count, Count) -> 274 | Count; 275 | collect(Msg, Timeout, I, Count) -> 276 | receive 277 | Msg -> collect(Msg, Timeout, I+1, Count) 278 | after 279 | Timeout -> I 280 | end. 281 | 282 | % check that for each day generated by DateF(I) for increasing I, Pattern[I] 283 | % weekly messages are received 284 | collect_weekly(DateF, TimeBefore, TimeAfter, Pattern) -> 285 | collect_weekly(DateF, TimeBefore, TimeAfter, Pattern, 0). 286 | 287 | collect_weekly(DateF, TimeBefore, TimeAfter, [N | PatternTail], I) -> 288 | erlcron:set_datetime({DateF(I), TimeBefore}), 289 | ?assertMatch(0, collect(weekly, 1000, 1)), 290 | erlcron:set_datetime({DateF(I), TimeAfter}), 291 | ?assertMatch(N, collect(weekly, 1000, N)), 292 | collect_weekly(DateF, TimeBefore, TimeAfter, PatternTail, I+1); 293 | collect_weekly(_DateF, _TimeBefore, _TimeAfter, [], _I) -> ok. 294 | -------------------------------------------------------------------------------- /src/erlcron.erl: -------------------------------------------------------------------------------- 1 | %%% @copyright Erlware, LLC. All Rights Reserved. 2 | %%% 3 | %%% This file is provided to you under the BSD License; you may not use 4 | %%% this file except in compliance with the License. 5 | -module(erlcron). 6 | 7 | -export([validate/1, 8 | cron/1, cron/2, cron/3, 9 | at/2, at/3, at/4, 10 | daily/2, daily/3, daily/4, 11 | weekly/3, weekly/4, weekly/5, 12 | monthly/3,monthly/4, monthly/5, 13 | cancel/1, 14 | epoch/0, 15 | epoch_seconds/0, 16 | datetime/0, 17 | ref_datetime/0, 18 | set_datetime/1, 19 | set_datetime/2, 20 | reset_datetime/0, 21 | get_all_jobs/0, 22 | multi_set_datetime/1, 23 | multi_set_datetime/2]). 24 | 25 | -export_type([job/0, 26 | job_ref/0, 27 | job_opts/0, 28 | cron_opts/0, 29 | job_start/0, 30 | job_end/0, 31 | run_when/0, 32 | callable/0, 33 | dow/0, 34 | dom/0, 35 | period/0, 36 | duration/0, 37 | constraint/0, 38 | cron_time/0, 39 | seconds/0, 40 | milliseconds/0]). 41 | 42 | 43 | %%%=================================================================== 44 | %%% Types 45 | %%%=================================================================== 46 | 47 | -type seconds() :: integer(). 48 | -type milliseconds():: integer(). 49 | 50 | -type cron_time() :: {integer(), am | pm} 51 | | {integer(), integer(), am | pm} 52 | | calendar:time(). 53 | -type constraint() :: {between, cron_time(), cron_time()}. 54 | -type duration() :: {integer(), hr | h | min | m | sec | s}. 55 | -type period() :: cron_time() | {every, duration()} 56 | | {every, duration(), constraint()}. 57 | -type dom() :: integer(). 58 | -type dow_day() :: mon | tue | wed | thu | fri | sat | sun. 59 | -type dow() :: dow_day() | [dow_day()]. 60 | -type callable() :: {M :: module(), F :: atom(), A :: [term()]} | 61 | fun(() -> term()) | 62 | fun((JobRef::job_ref(), calendar:datetime()) -> term()). 63 | -type run_when() :: {once, cron_time()} 64 | | {once, seconds()} 65 | | {daily, period()} 66 | | {weekly, dow(), period()} 67 | | {monthly, dom()|[dom()], period()}. 68 | 69 | -type job() :: {run_when(), callable()} 70 | | {run_when(), callable(), job_opts()} 71 | | #{id => job_ref(), interval => run_when(), execute => callable(), _ => any()}. 72 | 73 | %% should be opaque but dialyzer does not allow it 74 | -type job_ref() :: reference() | atom() | binary(). 75 | %% A job reference. 76 | 77 | -type job_start() :: fun((JobRef::job_ref()) -> ignore | any()). 78 | %% A function to be called before a job is started. If it returns the `ignore' 79 | %% atom, the job function will not be executed. 80 | 81 | -type job_end() :: fun((JobRef::job_ref(), 82 | Res :: {ok, term()} | {error, {Reason::term(), Stack::list()}}) 83 | -> term()). 84 | %% A function to be called after a job ended. The function is passed the 85 | %% job's result. 86 | 87 | -type job_opts() :: 88 | #{hostnames => [binary()|string()], 89 | id => term(), 90 | on_job_start => {Mod::atom(), Fun::atom()} | job_start(), 91 | on_job_end => {Mod::atom(), Fun::atom()} | job_end() 92 | }. 93 | %% Job options: 94 | %%