Current section

Files

Jump to
debouncer lib debouncer.ex
Raw

lib/debouncer.ex

defmodule Debouncer do
use Application
use GenServer
@moduledoc """
Debouncer executes a function call debounced. Debouncing is done one a per key basis:
```
Debouncer.apply(Key, fn() -> IO.puts("Hello World, debounced") end)
```
The third optional parameter is the timeout period in milliseconds
```
Debouncer.apply(Key, fn() -> IO.puts("Hello World, once per minute max") end, 60_000)
```
The variants supported are:
* apply() => Events are executed after the timeout
* immediate() => Events are executed immediately, and further events are delayed for the timeout
* immediate2() => Events are executed immediately, and further events are IGNORED for the timeout
* delay() => Each event delays the execution of the next event
```
EVENT X1---X2------X3-------X4----------
TIMEOUT ----------|----------|----------|-
===============================================
apply() ----------X2---------X3---------X4
immediate() X1--------X2---------X3---------X4
immediate2() X1-----------X3-------------------
delay() --------------------------------X4
```
"""
@spec immediate(term(), (() -> any()), non_neg_integer()) :: :ok
@doc """
immediate() executes the function immediately but blocks any further call
under the same key for the given timeout.
"""
def immediate(key, fun, timeout \\ 5000) do
do_cast(fn state ->
case Map.get(state, key) do
nil ->
execute(fun)
calltime = time() + timeout
ets_insert(calltime, key)
Map.put(state, key, {calltime, nil, timeout})
{calltime, _fun, _timeout} ->
Map.put(state, key, {calltime, fun, timeout})
end
end)
end
@spec immediate2(term(), (() -> any()), non_neg_integer()) :: :ok
@doc """
immediate2() executes the function immediately but blocks any further call
under the same key for the given timeout.
"""
def immediate2(key, fun, timeout \\ 5000) do
do_cast(fn state ->
case Map.get(state, key) do
nil ->
execute(fun)
calltime = time() + timeout
ets_insert(calltime, key)
Map.put(state, key, {calltime, nil, timeout})
{calltime, _fun, _timeout} ->
Map.put(state, key, {calltime, nil, timeout})
end
end)
end
@spec delay(term(), (() -> any()), non_neg_integer()) :: :ok
@doc """
delay() executes the function after the specified timeout t0 + timeout,
when delay is called multipe times the timeout is reset based on the
most recent call (t1 + timeout, t2 + timeout) etc... the fun is also updated
"""
def delay(key, fun, timeout \\ 5000) do
do_cast(fn state ->
calltime = time() + timeout
ets_insert(calltime, key)
Map.put(state, key, {calltime, fun})
end)
end
@spec apply(term(), (() -> any()), non_neg_integer()) :: :ok
@doc """
apply() executes the function after the specified timeout t0 + timeout,
when apply is called multiple times it does not affect the point
in time when the next call is happening (t0 + timeout) but updates the fun
"""
def apply(key, fun, timeout \\ 5000) do
do_cast(fn state ->
case Map.get(state, key) do
nil ->
calltime = time() + timeout
ets_insert(calltime, key)
Map.put(state, key, {calltime, fun, timeout})
{calltime, _fun, timeout} ->
Map.put(state, key, {calltime, fun, timeout})
end
end)
end
@spec cancel(term()) :: :ok
@doc """
cancel() deletes the latest event if it hasn't triggered yet.
"""
def cancel(key) do
do_cast(fn state ->
case Map.get(state, key) do
nil ->
state
{calltime, _fun} ->
Map.put(state, key, {calltime, nil})
{calltime, _fun, timeout} ->
Map.put(state, key, {calltime, nil, timeout})
end
end)
end
######################## CALLBACKS ####################
@doc false
def start(_type, _args) do
import Supervisor.Spec, warn: false
children = [worker(Debouncer, [])]
Supervisor.start_link(children, strategy: :one_for_one, name: Debouncer.Supervisor)
end
@doc false
@spec start_link() :: :ignore | {:error, any} | {:ok, pid}
def start_link() do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
@doc false
def init(_arg) do
{:ok, _} = :timer.send_interval(100, :tick)
__MODULE__ = :ets.new(__MODULE__, [{:keypos, 1}, :ordered_set, :named_table])
{:ok, %{}}
end
######################## INTERNAL METHOD ####################
defp do_cast(fun) do
GenServer.cast(__MODULE__, fun)
end
def handle_cast(fun, state) do
{:noreply, fun.(state)}
end
defp ets_insert(calltime, key) do
case :ets.lookup(__MODULE__, calltime) do
[] -> :ets.insert(__MODULE__, {calltime, [key]})
[{_, keys}] -> :ets.insert(__MODULE__, {calltime, [key | keys]})
end
end
def handle_info(:tick, state) do
{:noreply, update(state, time())}
end
defp update(state, now) do
case :ets.first(__MODULE__) do
:"$end_of_table" ->
state
ts when ts > now ->
state
ts ->
state =
hd(:ets.lookup(__MODULE__, ts))
|> elem(1)
|> Enum.reduce(state, fn key, state ->
case Map.get(state, key) do
# Handling apply(), immediate(), immediate2()
{^ts, nil, _timeout} ->
Map.delete(state, key)
# Executing and putting marker for next event
{^ts, fun, timeout} ->
execute(fun)
calltime = ts + timeout
ets_insert(calltime, key)
Map.put(state, key, {calltime, nil, timeout})
# delay() goes here
{^ts, fun} ->
execute(fun)
Map.delete(state, key)
_ ->
state
end
end)
:ets.delete(__MODULE__, ts)
update(state, now)
end
end
defp execute(nil) do
:ok
end
defp execute(fun) do
spawn(fun)
end
defp time() do
System.monotonic_time(:millisecond)
end
end