├── .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 [![Build Status](https://travis-ci.org/pragdave/periodic.svg?branch=master)](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 | 19 | 21 | 44 | 49 | 54 | 55 | 57 | 58 | 60 | image/svg+xml 61 | 63 | 64 | 65 | 66 | 67 | 72 | 77 | 82 | 83 | 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 | --------------------------------------------------------------------------------