Current section
Files
Jump to
Current section
Files
lib/timeout.ex
defmodule Timeout do
@moduledoc """
An module for manipulating configurable timeouts.
Comes with the following features.
* Randomizing within +/- of a given percent range
* Backoffs with an optional maximum.
* Timer management using the above configuration.
### Backoff
Backoffs can be configured using the `:backoff` and `:backoff_max` options
when creating a timeout with `new/1`. Each call to `next/1` will increment the
timeout by the given backoff and store that new value as the current timeout.
This comes in handy when you might want to backoff a reconnect attempt or a
polling process during times of low activity.
t = Timeout.new(100, backoff: 1.25)
Timeout.current(t) # => 100
t = Timeout.next(t)
Timeout.current(t) # => 100
t = Timeout.next(t)
Timeout.current(t) # => 125
t = Timeout.next(t)
Timeout.current(t) # => 156
*Note* how the first call to next returns the initial value. If we incremented
it on the first call, the initial value would never be used.
### Randomizing
This module is capabable of randomizing within `+/-` of a given percent range.
This feature can be especially useful if you want to avoid something like the
[Thundering Heard Problem][thp] when multiple processes might be sending
requests to a remote service. For example:
t = Timeout.new(100, random: 0.10)
Timeout.current(t) # => 95
Timeout.current(t) # => 107
Timeout.current(t) # => 108
Timeout.current(t) # => 99
Timeout.current(t) # => 100
This works in combination with the `backoff` configuration as well:
t = Timeout.new(100, backoff: 1.25, random: 0.10)
t = Timeout.next(t)
Timeout.current(t) # => Within +/- 10% of 100
t = Timeout.next(t)
Timeout.current(t) # => Within +/- 10% of 125
t = Timeout.next(t)
Timeout.current(t) # => Within +/- 10% of 156
### Timers
The main reason for writing this library was to be able to configure a timeout
once, then be able to schedulle server messages without having to keep track
of the timeout values being used.
After configuring your timeout using the options above, you can start
scheduling messages using the following workflow:
t = Timeout.new(100, backoff: 1.25, backoff_max: 1_250, random: 0.10)
{t, delay} = Timeout.send_after(t, self(), :message)
IO.puts("Message delayed for: \#{delay}")
receive do
:message -> IO.puts("Received message!")
end
The timer API methods include:
* `send_after/3`: Sends the message, returns `{timeout, delay}`.
* `send_after!/3`: Same as above, but just returns the timeout.
* `cancel_timer/1`: Cancels the stored timer, returns `{timeout, result}`.
* `cancel_timer!/1`: Same as above, but just returns the timeout.
[thp]: https://en.wikipedia.org/wiki/Thundering_herd_problem
"""
@type timeout_value :: pos_integer
@typedoc "Represents timeout growth factor. Should be `> 1`."
@type backoff :: pos_integer | float | nil
@typedoc "Represents the max growth of a timeout using backoff."
@type backoff_max:: pos_integer | nil
@typedoc "Represents a % range when randomizing. Should be `0 < x < 1`."
@type random :: float | nil
@type options :: [backoff: backoff, backoff_max: backoff_max, random: random]
@type t :: %__MODULE__{
base: timeout_value,
timeout: timeout_value,
backoff: backoff,
backoff_round: non_neg_integer,
backoff_max: backoff_max,
random: {float, float} | nil,
timer: reference | nil
}
defstruct ~w(base timeout backoff backoff_round backoff_max random timer)a
@doc """
Builds a `Timeout` struct.
Accepts an integer timeout value and the following optional configuration:
* `:backoff` - A backoff growth factor for growing a timeout period over time.
* `:backoff_max` - Given `:backoff`, will never grow past max.
* `:random` - A float indicating the `%` timeout values will be randomized
within. Expects `0 < :random < 1` or raises an `ArgumentError`. For example,
use `0.10` to randomize within +/- 10% of the desired timeout.
For more information, see `Timeout`.
"""
@spec new(timeout_value, options) :: t
def new(timeout, opts \\ []) when is_integer(timeout) do
%__MODULE__{
base: timeout,
timeout: timeout,
backoff: Keyword.get(opts, :backoff),
backoff_round: 0,
backoff_max: Keyword.get(opts, :backoff_max),
random: opts |> Keyword.get(:random) |> parse_random_max_min()
}
end
@doc """
Resets the current timeout.
"""
@spec reset(t) :: t
def reset(t = %__MODULE__{base: base}) do
%{t | backoff_round: 0, timeout: base}
end
@doc """
Increments the current timeout based on the `backoff` configuration.
If there is no `backoff` configured, this function simply returns the timeout
as is. If `backoff_max` is configured, the timeout will never be incremented
above that value.
**Note:** The first call to `next/1` will always return the initial timeout
first.
"""
@spec next(t) :: t
def next(t = %__MODULE__{backoff: nil}), do: t
def next(t = %__MODULE__{base: base, timeout: nil}), do: %{t | timeout: base}
def next(t = %__MODULE__{timeout: c, backoff_max: c}), do: t
def next(t = %__MODULE__{base: c, backoff: b, backoff_round: r, backoff_max: m}) do
timeout = round(c * :math.pow(b, r))
%{t | backoff_round: r + 1, timeout: (m && (timeout > m and m)) || timeout}
end
@doc """
Returns the timeout value represented by the current state.
iex> Timeout.new(100) |> Timeout.current()
100
If `backoff` was configured, returns the current timeout with backoff applied:
iex> t = Timeout.new(100, backoff: 1.25) |> Timeout.next() |> Timeout.next()
...> Timeout.current(t)
125
If `random` was configured, the current timeout out is randomized within the
configured range:
iex> t = Timeout.new(100, random: 0.10)
...> if Timeout.current(t) in 91..110, do: true, else: false
true
"""
@spec current(t) :: timeout_value
def current(%__MODULE__{base: base, timeout: nil, random: random}),
do: calc_current(base, random)
def current(%__MODULE__{timeout: timeout, random: random}),
do: calc_current(timeout, random)
@doc """
Sends a process a message with `Process.send_after/3` using the given timeout,
the stores the resulting timer on the struct.
Sends the message to `self()` if pid is omitted, otherwise sends to the given
`pid`.
Always calls `next/1` first on the given timer, then uses the return value of
`current/1` to delay the message.
This function is a convienence wrapper around the following workflow:
t = Timeout.new(100, backoff: 1.25) |> Timeout.next()
timer = Process.send_after(self(), :message, Timeout.current(t))
t = %{t | timer: timer}
Returns `{%Timeout{}, delay}` where delay is the message schedule delay.
"""
@spec send_after(t, pid, term) ::{t, pos_integer}
def send_after(t = %__MODULE__{}, pid \\ self(), message) do
t = next(t)
delay = current(t)
{%{t | timer: Process.send_after(pid, message, delay)}, delay}
end
@doc """
Calls `send_after/3`, but returns only the timeout struct.
"""
@spec send_after!(t, pid, term) :: t
def send_after!(t = %__MODULE__{}, pid \\ self(), message) do
with {timeout, _delay} <- send_after(t, pid, message), do: timeout
end
@doc """
Cancels the stored timer.
Returns `{%Timeout{}, result}` where result is the value returned by calling
`Process.cancel_timer/1` on the stored timer reference.
"""
@spec cancel_timer(t) :: {t, non_neg_integer | false | :ok}
def cancel_timer(t = %__MODULE__{timer: nil}), do: {t, false}
def cancel_timer(t = %__MODULE__{timer: timer}) when is_reference(timer) do
{%{t | timer: nil}, Process.cancel_timer(timer)}
end
@doc """
Calls `cancel_timer/1` but returns only the timeout struct.
Returns `{%Timeout{}, result}` where result is the value returned by calling
`Process.cancel_timer/1` on the stored timer reference.
"""
@spec cancel_timer!(t) :: t
def cancel_timer!(t = %__MODULE__{}) do
with {timeout, _result} <- cancel_timer(t), do: timeout
end
defp calc_current(timeout, nil), do: timeout
defp calc_current(timeout, {rmax, rmin}) do
max = round(timeout * rmax)
min = round(timeout * rmin)
min + do_rand(max - min)
end
defp parse_random_max_min(nil), do: nil
defp parse_random_max_min(range) when is_float(range) and range > 0 and range < 1 do
{1.0 + range, 1.0 - range}
end
defp parse_random_max_min(range) do
raise ArgumentError, "Invalid option for :random. Expected 0 < float < 1, got: #{range}"
end
defp do_rand(0), do: 0
defp do_rand(n), do: :rand.uniform(n)
end