├── .gitignore
├── README.md
├── assets
└── images
│ └── clock.svg
├── lib
├── periodic.ex
└── periodic
│ ├── application.ex
│ └── runner.ex
├── license.md
├── mix.exs
└── test
├── periodic_test.exs
└── test_helper.exs
/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where 3rd-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Ignore package tarball (built via "mix hex.build").
23 | periodic-*.tar
24 |
25 | /.elixir_ls/
26 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
3 |
4 | # Periodic: run functions at intervals [](https://travis-ci.org/pragdave/periodic)
5 |
6 |
7 | The Periodic supervisor manages a dynamic set of tasks. Each of these
8 | tasks is run repeatedly at a per-task specified interval.
9 |
10 | A task is repreented as a function. It receives a single parameter, its
11 | current state. When complete, this function can return
12 |
13 | * `{ :ok, new_state }` to have itself rescheduled with a (potentially)
14 | updated state.
15 |
16 | * `{ :change_interval, new_interval, new_state }` to reschedule itself
17 | with a new state, but updating the interval betweeen schedules.
18 |
19 | * `{ :stop, :normal }` to exit gracefully.
20 |
21 | * any other return value will be treated as an error.\
22 |
23 | All intervals are specified in milliseconds.
24 |
25 | ## What does it look like?
26 |
27 | mix.exs:
28 |
29 | ~~~ elixir
30 | deps: { :periodic, ">= 0.0.0" },
31 | ~~~
32 |
33 | application.ex
34 |
35 | ~~~ elixir
36 | child_spec = [
37 | Periodic,
38 | MyApp,
39 | . . .
40 | ]
41 | ~~~
42 |
43 | First a silly example:
44 |
45 | ~~~ elixir
46 | defmodule Silly do
47 | use GenServer
48 |
49 | def callback(state = [{ label, count }]) do
50 | IO.inspect state
51 | { :ok, [ { label, count + 100 }]}
52 | end
53 |
54 | def start_link(_) do
55 | GenServer.start_link(__MODULE__, nil)
56 | end
57 |
58 | def init(_) do
59 | Periodic.repeat({ __MODULE__, :callback }, 500, state: [ one: 1 ])
60 | Periodic.repeat({ __MODULE__, :callback }, 300, state: [ two: 2 ], offset: 100)
61 | { :ok, nil }
62 | end
63 |
64 | end
65 | ~~~
66 |
67 | The calls to `Periodic.repeat` will cause the `callback` function to be
68 | called in two different sequences: the first time it will be called
69 | every 500ms, and it will also be called every 300ms. Each sequence of
70 | calls will maintain its own state.
71 |
72 | This will output:
73 |
74 | ~~~
75 | Compiling 1 file (.ex)
76 | [one: 1]
77 | [two: 2]
78 | [two: 102]
79 | [one: 101]
80 | [two: 202]
81 | [one: 201]
82 | [two: 302]
83 | [two: 402]
84 | . . .
85 | ~~~
86 |
87 | As something more complex, here's a genserver that fetches data from two
88 | feeds. The first is fetched every 30 seconds, and the second every 60s.
89 |
90 | ~~~ elixir
91 | defmodule Fetcher do
92 | use GenServer
93 |
94 | def start_link(_) do
95 | GenServer.start_link(__MODULE__, nil)
96 | end
97 |
98 | def init(_) do
99 | { :ok, _ } = Periodic.repeat({ __MODULE__, :fetch }, 30_000,
100 | state: %{ feed: Stocks, respond_to: self() })
101 |
102 | { :ok, _ } = Periodic.repeat({ __MODULE__, :fetch }, 60_000,
103 | state: %{ feed: Bonds, respond_to: self() }, offset: 15_000)
104 | { :ok, %{} }
105 | end
106 |
107 | # this function is run by the two task runners created in `init/1)`. They
108 | # fetch data from the feed whose name is in the state, and then send the
109 | # result back to the original server
110 |
111 | def fetch(task) do
112 | data = task.feed.fetch()
113 | Fetcher.handle_data(task.respond_to, task.feed, data)
114 | { :ok, state }
115 | end
116 |
117 | # and this function forwards the feed response on to the server
118 | def handle_data(worker_pid, feed, data) do
119 | GenServer.cast(worker_pid, { incoming, feed, data })
120 | end
121 |
122 | def handle_cast({ :incoming, Stocks, data }, state) do
123 | ## ...
124 | end
125 |
126 | def handle_cast({ :incoming, Bonds, data }, state) do
127 | ## ...
128 | end
129 | end
130 | ~~~
131 |
132 | Notes:
133 |
134 | * In the real world you'd likely split this into multiple modules.
135 |
136 | * The parameters to the first call to `Periodic.repeat` say run
137 | `Fetcher.fetch` every 30s, passing it a map containing the name
138 | of a feed and the pid to send the data to.
139 |
140 | * the second call to `Fetcher.fetch` sets up a second schedule. This
141 | happens to call the same function, but every 60s. It also offsets
142 | the time of these calls (starting with the first) by 15s
143 |
144 | This means the timeline for calls to the function will be:
145 |
146 | | time from start | call |
147 | |-----------------|-------------------------------|
148 | | +0s | fetch{feed: Stocks, ...} |
149 | | +15s | fetch{feed: Bonds, ...} |
150 | | +30s | fetch{feed: Stocks, ...} |
151 | | +60s | fetch{feed: Stocks, ...} |
152 | | +75s | fetch{feed: Bonds, ...} |
153 | | +90s | fetch{feed: Stocks, ...} |
154 | | +120s | fetch{feed: Stocks, ...} |
155 | | +135s | fetch{feed: Bonds, ...} |
156 | | . . . | |
157 |
158 | * The `fetch` function gets data for the appropriate feed, and then
159 | calls back into the original module, passing the pid of the genserver,
160 | the name of the feed and the data.
161 |
162 | * The `handle_data` function it calls just forwards the request on to
163 | the genserver.
164 |
165 | (Technically the call to `GenServer.cast` could have been made
166 | directly in the `fetch` function, but in our mythical _real world_,
167 | it's likely the periodically run functions would be decoupled from the
168 | genserver.
169 |
170 | ### The API
171 |
172 | To cause a function to be invoked repeatedly every so many milliseconds,
173 | use:
174 |
175 | ~~~ elixir
176 | { :ok, pid } = Periodic.repeat(func_spec, interval, options \\ [])
177 | ~~~
178 |
179 | * `func_spec` may be an anonymous function of arity 1, a 2-tuple
180 | containing the name of a module and the name of a function, or just
181 | the name of the module (in which case the function is assumed to be
182 | named `run/1`.
183 |
184 | * The `interval` specifies the number of milliseconds between executions
185 | of the function. `Periodic` makes some attempt to minimize drift of
186 | this timing, but you should treat the value as approximate: you'll see
187 | some spreading of the interval timing of perhaps a millisecond on some
188 | iterations.
189 |
190 | * The options list make contain:
191 |
192 | * `state: ` _term_
193 |
194 | The initial state that is passed as a parameter when the function is
195 | first executeded.
196 |
197 | * `name:` _name_
198 |
199 | A name for the task. This can be used subsequently to terminate it.
200 |
201 | * `offset:` _ms_
202 |
203 | An offset (in milliseconds) to be applied before the first execution
204 | of the function. This can be used to stagger executions of multiple
205 | sets of periodic functions if their intervals would otherwise cause
206 | them to execute at the same time.
207 |
208 | You can remove a previously added periodic function with
209 |
210 | ~~~ elixir
211 | Periodic.stop_task(pid)
212 | ~~~
213 |
214 | where `pid` is the value returned by `repeat/3`
215 |
216 |
217 | ### The Callback Function
218 |
219 | You write functions that `Periodic` will call. These will have the spec:
220 |
221 | ~~~ elixir
222 | @typep state :: term()
223 | @typep periodic_callback_return ::
224 | { :ok, state() } |
225 | { :change_interval, new_interval::integer(), state() } |
226 | { :stop, :normal } |
227 | other :: any()
228 |
229 | @spec periodic_callback(state :: state()) :: periodic_callback_return()
230 | ~~~
231 |
232 | ### Runtime Charactertics
233 |
234 | * `Periodic` is a `DynamicSupervisor` which should be started by one of
235 | your application's own supervisors.
236 |
237 | * Each call to `Periodic.repeat` creates a new worker process. This
238 | worker spends most of its time waiting for the interval timer to
239 | trigger, at which point it invokes the function you passed it, then
240 | resets the timer.
241 |
242 | * If a function takes more time to execute than the interval time, then
243 | the next call to that function will happen immediately, and all
244 | subsequent calls to it will be timeshifted by the overrun.
245 |
246 |
247 | > See [license.md](license.md) for copyright and licensing information.
--------------------------------------------------------------------------------
/assets/images/clock.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
84 |
--------------------------------------------------------------------------------
/lib/periodic.ex:
--------------------------------------------------------------------------------
1 | defmodule Periodic do
2 | use DynamicSupervisor
3 |
4 | @moduledoc File.read!("README.md")
5 |
6 | @spec start_link(any()) :: :ignore | {:error, any()} | {:ok, pid()}
7 | def start_link(arg) do
8 | DynamicSupervisor.start_link(__MODULE__, arg, name: __MODULE__)
9 | end
10 |
11 | @impl true
12 | def init(_arg) do
13 | DynamicSupervisor.init(strategy: :one_for_one)
14 | end
15 |
16 |
17 | @doc """
18 | Create a process that will run a function every `n` milliseconds.
19 |
20 | The funcion to be run can be passed as
21 |
22 | * an anonymous function of arity 1,
23 | * a module conntaining a `run/1` function
24 | * a `{module, function_name}` tuple that designates a zero-arity function.
25 |
26 | THe function will be passed a state, and may return:
27 |
28 | * `{ :ok, updated_state }`, in which case it will be scheduled to run again
29 | after an interval with that new state.
30 |
31 | * `{ :change_interval, new_interval, updated_state }` which will change the
32 | reschedule interval to `new_interval`ms.
33 |
34 | * `{ :stop, reason }`, in which case the function will not be rescheduled. You
35 | probably want to use `:normal` for the reason.
36 |
37 |
38 | """
39 | @spec repeat(atom() | tuple() | ( (term(), integer())-> term()), integer(), keyword()) :: { :ok, pid } | { :error, term() }
40 |
41 | def repeat(task_spec, interval, options \\ [])
42 | when (is_tuple(task_spec) or is_atom(task_spec) or is_function(task_spec))
43 | and is_integer(interval) and is_list(options)
44 |
45 | do
46 | DynamicSupervisor.start_child(__MODULE__, { Periodic.Runner, { task_spec, interval, options }})
47 | end
48 |
49 |
50 | @doc """
51 | Terminate a periodic task. Pass this either the name or the pid of the task to
52 | be terminated,
53 | """
54 |
55 | def stop_task(name) do
56 | DynamicSupervisor.terminate_child(__MODULE__, name)
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/lib/periodic/application.ex:
--------------------------------------------------------------------------------
1 | defmodule Periodic.Application do
2 | # See https://hexdocs.pm/elixir/Application.html
3 | # for more information on OTP Applications
4 | @moduledoc false
5 |
6 | use Application
7 |
8 | def start(_type, _args) do
9 | # List all child processes to be supervised
10 | children = [
11 | # Starts a worker by calling: Periodic.Worker.start_link(arg)
12 | # {Periodic.Worker, arg},
13 | ]
14 |
15 | # See https://hexdocs.pm/elixir/Supervisor.html
16 | # for other strategies and supported options
17 | opts = [strategy: :one_for_one, name: Periodic.Supervisor]
18 | Supervisor.start_link(children, opts)
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/lib/periodic/runner.ex:
--------------------------------------------------------------------------------
1 | defmodule Periodic.Runner do
2 | use GenServer
3 |
4 | def start_link({ task_spec, interval, options }) when is_list(options) do
5 | start_opts = case options[:name] do
6 | nil -> []
7 | name -> [ name: name ]
8 | end
9 | GenServer.start_link(__MODULE__, { task_spec, interval, options }, start_opts)
10 | end
11 |
12 | def init({ task_spec, interval, options }) do
13 | task_state = %{
14 | interval: interval,
15 | offset: options[:offset] || 0,
16 | task: task_spec,
17 | state: options[:state]
18 | }
19 | :timer.send_after(task_state.offset, :trigger)
20 | { :ok, task_state }
21 | end
22 |
23 | def handle_info(:trigger, task_state) do
24 |
25 | started = now_ms()
26 |
27 | case run_function(task_state.task, task_state.state) do
28 | { :ok, new_state } ->
29 | next_time = task_state.interval - (now_ms() - started)
30 | :timer.send_after(next_time, :trigger)
31 | { :noreply, %{ task_state | state: new_state }}
32 |
33 | { :change_interval, interval, new_state } ->
34 | next_time = interval - (now_ms() - started)
35 | :timer.send_after(next_time, :trigger)
36 | { :noreply, %{ task_state | state: new_state, interval: interval }}
37 |
38 |
39 | { :stop, reason } ->
40 | { :stop, reason, task_state }
41 |
42 | other ->
43 | { :stop, :error, other }
44 | end
45 | end
46 |
47 | defp run_function({m, f}, state) do
48 | apply(m, f, [state])
49 | end
50 |
51 | defp run_function(module, state) when is_atom(module) do
52 | apply(module, :run, [state])
53 | end
54 |
55 | defp run_function(fun, state) when is_function(fun) do
56 | fun.(state)
57 | end
58 |
59 | defp now_ms() do
60 | :erlang.monotonic_time(:millisecond)
61 | end
62 | end
63 |
--------------------------------------------------------------------------------
/license.md:
--------------------------------------------------------------------------------
1 | # Legal
2 |
3 | Copyright © 2018 David Thomas (dave@pragdave.me)
4 |
5 | All rights reserved.
6 |
7 | Redistribution and use in source and binary forms, with or without
8 | modification, are permitted provided that the following conditions are
9 | met:
10 |
11 | * Redistributions of source code must retain the above copyright
12 | notice, this list of conditions and the following disclaimer.
13 |
14 | * Redistributions in binary form must reproduce the above copyright
15 | notice, this list of conditions and the following disclaimer in the
16 | documentation and/or other materials provided with the distribution.
17 |
18 | * Neither the name "David Thomas" (or any name likely to be construed as
19 | referring to David Thomas, including but not limited to the nickname
20 | _pragdave_) nor the names of its contributors may be used to endorse
21 | or promote products derived from this software without specific prior
22 | written permission.
23 |
24 |
25 | > THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
26 | > "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
27 | > LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
28 | > A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL DAVID THOMAS BE
29 | > LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
30 | > CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
31 | > SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
32 | > BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
33 | > WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
34 | > OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN
35 | > IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
36 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Periodic.MixProject do
2 | use Mix.Project
3 |
4 | @description """
5 | Run functions periodically: each function can be called on a different schedule.
6 | """
7 |
8 | @source_url "https://github.com/pragdave/periodic"
9 |
10 | @package [
11 | files: ~w( lib README.md license.md ),
12 | licenses: [ "BSD3" ],
13 | links: %{ "GitHub" => @source_url }
14 | ]
15 |
16 | def project do
17 | [
18 | app: :periodic,
19 | version: "0.1.0",
20 | elixir: "~> 1.6",
21 | deps: [ {:ex_doc, "~> 0.14", only: :dev} ],
22 |
23 | description: @description,
24 | package: @package,
25 | source_url: @source_url,
26 |
27 | start_permanent: Mix.env() == :prod,
28 | ]
29 | end
30 |
31 | def application do
32 | [
33 | mod: {Periodic.Application, []}
34 | ]
35 | end
36 |
37 | end
38 |
--------------------------------------------------------------------------------
/test/periodic_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PeriodicTest do
2 | use ExUnit.Case
3 |
4 | defmodule Dummy do
5 | def do_it(state) do
6 | { :ok, PeriodicTest.bump_state(state) }
7 | end
8 |
9 | def run(state) do
10 | do_it(state)
11 | end
12 | end
13 |
14 |
15 | test "runs an anonymous function every 5 ms" do
16 | Periodic.start_link(nil)
17 | Periodic.repeat(fn state -> { :ok, bump_state(state) } end, 5, create_name_and_state(:one))
18 | :timer.sleep(17)
19 | assert Agent.get(:state_of_one, &(&1)) in 3..4
20 | DynamicSupervisor.stop(Periodic)
21 | end
22 |
23 |
24 | test "runs a {m,f} every 10 ms" do
25 | Periodic.start_link(nil)
26 | Periodic.repeat({ Dummy, :do_it }, 10, create_name_and_state(:two))
27 | :timer.sleep(35)
28 | assert Agent.get(:state_of_two, &(&1)) in 3..4
29 | DynamicSupervisor.stop(Periodic)
30 | end
31 |
32 | test "runs a module every 20 ms" do
33 | Periodic.start_link(nil)
34 | Periodic.repeat(Dummy, 20, create_name_and_state(:three))
35 | :timer.sleep(70)
36 | assert Agent.get(:state_of_three, &(&1)) in 3..4
37 | DynamicSupervisor.stop(Periodic)
38 | end
39 |
40 |
41 | test "runs multiple tasks" do
42 | Periodic.start_link(nil)
43 | Periodic.repeat(Dummy, 10, create_name_and_state(:four_10))
44 | Periodic.repeat(Dummy, 20, create_name_and_state(:four_20))
45 | :timer.sleep(55)
46 | assert Agent.get(:state_of_four_10, &(&1)) in 5..6
47 | assert Agent.get(:state_of_four_20, &(&1)) in 2..3
48 | DynamicSupervisor.stop(Periodic)
49 | end
50 |
51 | test "honors an offset" do
52 | Periodic.start_link(nil)
53 | Periodic.repeat(Dummy, 10, [ {:offset, 50} | create_name_and_state(:three) ])
54 | :timer.sleep(65)
55 | assert Agent.get(:state_of_three, &(&1)) == 2
56 | DynamicSupervisor.stop(Periodic)
57 | end
58 | #-----------+
59 | # Helpers |
60 | #-----------+
61 |
62 | defp create_name_and_state(name) do
63 | state_name = :"state_of_#{name}"
64 | { :ok, pid } = Agent.start_link(fn -> 0 end, name: state_name)
65 | [ name: name, state: { state_name, pid } ]
66 | end
67 |
68 | def bump_state({ state_name, pid }) do
69 | Agent.update(pid, fn n -> n + 1 end)
70 | { state_name, pid }
71 | end
72 | end
73 |
--------------------------------------------------------------------------------
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 |
--------------------------------------------------------------------------------