Packages

Debouncer with HTTP client support.

Current section

Files

Jump to
boing lib boing.ex
Raw

lib/boing.ex

defmodule Boing do
@moduledoc """
A key-based debouncer.
Actions are debounced by specifying a key for related actions. When the first action for that key
occurs, a 1 second (by default, but configurable) timer is started. The timer is restarted for each
subsequent request to the same key that occurs before the timer expires. When the timer expires,
the most recent request is permitted, and the previous requests are debounced.
## Implementation details
Internally, this module uses `Registry` and `DynamicSupervisor` to start a unique registered
`m::gen_statem` for each key with an active timer. Requests make a blocking call to that process,
which when the timer expires returns `:ok` to the most recent request, and
`{:error, debounced: key}` to all others.
Processes are short-lived and de-register themselves once the timer expires.
The keys used are associated with a `Registry` that is started as part of Boing's supervision tree,
and can be considered global. All calls made to `Boing.debounce/2` for the same key within the
timeout period are made to the same `m::gen_statem` process, which will block and return
when the timer expires.
"""
@behaviour :gen_statem
require Logger
@typep data :: {
key :: Registry.key(),
timeout :: timeout(),
requests :: [:gen_statem.from()]
}
@impl :gen_statem
def callback_mode, do: :handle_event_function
@impl :gen_statem
@spec init({key :: Registry.key(), timeout :: timeout()}) :: {:ok, :debounce, data()}
def init({key, timeout}) do
{:ok, :debounce, {key, timeout, []}}
end
@impl :gen_statem
def handle_event({:call, from}, :debounce, :debounce, {key, timeout, requests}) do
{:keep_state, {key, timeout, [from | requests]}, {:state_timeout, timeout, :reply}}
end
@impl :gen_statem
def handle_event(:state_timeout, :reply, :debounce, {key, _timeout, [success | debounced]}) do
debounced_replies = for pid <- debounced, do: {:reply, pid, {:error, debounced: key}}
{:stop_and_reply, :normal, [{:reply, success, :ok} | debounced_replies]}
end
@impl :gen_statem
def handle_event(type, content, :debounce, {key, _timeout, _callers}) do
Logger.debug("#{inspect(type)} event. Key: #{inspect(key)}. Content: #{inspect(content)}")
:keep_state_and_data
end
@impl :gen_statem
def terminate(_reason, _state, _data = {key, _timeout, _requests}) do
Registry.unregister(Boing.Registry, key)
end
@doc """
Debounce requests for the specified key.
`timeout` is the number of milliseconds to wait before allowing the request. Defaults to 1000ms.
Initially, no timer is active. When this function is called for a `key` which has no active timer,
a new timer is started for `timeout` milliseconds (1 second by default). Any subsequent calls while
the timer is active restart the timer. When the timer expires, `:ok` is returned to the most
recent call, and `{:error, debounced: key}` to all the other calls that were made while the timer
was active.
"""
@spec debounce(key :: Registry.key(), timeout :: timeout() | nil) ::
:ok | {:error, debounced: Registry.key()}
def debounce(key, timeout \\ nil) do
# Create a registered debouncer for each key.
# These are short-lived and deregister themselves after the debounce window.
server_ref = {:via, Registry, {Boing.Registry, key}}
timeout = timeout || :timer.seconds(1)
child_spec = %{
id: :unused,
start: {:gen_statem, :start_link, [server_ref, __MODULE__, {key, timeout}, []]},
restart: :transient
}
case DynamicSupervisor.start_child(Boing.DynamicSupervisor, child_spec) do
{:ok, _} -> :ok
{:error, {:already_started, _}} -> :ok
end
try do
case :gen_statem.call(server_ref, :debounce) do
:ok -> :ok
{:error, debounced: ^key} -> {:error, debounced: key}
end
catch
# try again if the process was shut down between looking it up and calling
:exit, {:normal, {:gen_statem, :call, [^server_ref, :debounce, _timeout]}} ->
debounce(key, timeout)
:exit, {:noproc, {:gen_statem, :call, [^server_ref, :debounce, _timeout]}} ->
debounce(key, timeout)
end
end
end