Current section
Files
Jump to
Current section
Files
lib/od_counter.ex
defmodule ODCounter do
@moduledoc """
`m::counters` but without ceremony.
It provides functions similar to those `m::counters` has but instead of indexes
it uses arbitrary term as keys.
Every function or macro can be called with arbitrary term as the counter key.
ODCounter will try it's best to detect the literal at compile time and to
create an association between the term and index at compile time. However,
if it is not possible to detect the literal value at compile time,
the association will be safely stored in `:persistent_term` in runtime.
It is possible to disable the runtime fallback by setting the `runtime_size` option of `init/2` to 0.
See `t:init_option/0` for more information
> #### Warning {: .warning}
>
> Any term passed as the counter key will be added to `persistent_term`, and therefore it won't be
> garbage collected automatically. In order to clear associations table, you must call `remove/1`
>
> **Example:**
> ```
> very_large_list = Enum.to_list(1..1000)
> ODCounter.init(:structures)
> ODCounter.get(:structures, very_large_list) # very_large_list won't be garbage collected now
> ```
> #### Tip {: .tip}
>
> In order to achieve maximum efficieny, you should pass keys as literals,
> not variables or function calls
>
> **Example:**
> ```
> ODCounter.init(:letters)
> ODCounter.add(:letters, :a) # fast
> b = :b
> ODCounter.add(:letters, b) # slow
> ```
"""
defmodule :odcounter_ct do
@moduledoc false
def all(_), do: %{}
def atoi(_, _), do: nil
def max(_name), do: 0
end
## Runtime
@type name :: atom()
@type counter :: any()
@typedoc """
Options passed to the `init/4` function.
* `counters` (`t:counters.init/0`) — A list of options passed to `:counters.new/2`.
* `runtime_size` (`t:non_neg_integer/0`) — A maximum amount of counters which are added at runtime to be supported.
Setting this value to `0` essentially disables the runtime counters
"""
@type init_option :: {:counters, list()}
| {:runtime_size, non_neg_integer()}
@doc """
Initializes the counters.
## Example
iex> ODCounter.init(MySuperCounter)
iex> ODCounter.get(MySuperCounter, :key)
0
* Every value of the counter is initialized to 0 by default
* If the counter was initialized before, this function will fail with `ArgumentError`
Refer to `t:init_option/0` for information about `opts`
"""
@spec init(name(), [init_option()]) :: :ok
def init(name, opts \\ []) do
with {_, _} <- :persistent_term.put({__MODULE__, name}, []) do
raise ArgumentError, "ODCounter #{inspect(name)} is already initialized"
end
counters_opts = Keyword.get(opts, :counters, [])
runtime_size = Keyword.get(opts, :runtime_size, 1024)
counters_ref = :counters.new(:odcounter_ct.max(name) + runtime_size, counters_opts)
index_atomics_ref = :atomics.new(1, [])
:atomics.add(index_atomics_ref, 1, :odcounter_ct.max(name) + 1)
:persistent_term.put({__MODULE__, name}, {counters_ref, index_atomics_ref})
:ok
end
@doc """
Removes the counter and all runtime keys
## Example
iex> ODCounter.init(:sheeps)
iex> ODCounter.add(:sheeps, :jumped_over_the_fence, 1)
iex> ODCounter.get(:sheeps, :jumped_over_the_fence)
1
iex> ODCounter.remove(:sheeps)
iex> ODCounter.get(:sheeps, :jumped_over_the_fence)
** (ArgumentError) errors were found at the given arguments:
...
"""
@spec remove(name()) :: :ok
def remove(name) do
:persistent_term.erase({__MODULE__, name})
for {{:odcounter, ^name, _counter} = key, _index} <- :persistent_term.get() do
:persistent_term.erase(key)
end
:ok
end
@doc """
Resets all counters in the namespace to 0 (or `to`)
## Example
iex> ODCounter.init(:errors)
iex> ODCounter.add(:errors, 400)
iex> ODCounter.add(:errors, 500, 5)
iex> ODCounter.reset(:errors)
iex> ODCounter.to_map(:errors)
%{}
"""
@spec reset(name()) :: :ok
def reset(name, to \\ 0) do
{counters_ref, index_atomics_ref} = :persistent_term.get({__MODULE__, name})
max = :atomics.get(index_atomics_ref, 1)
for i <- 1..max//1 do
:counters.put(counters_ref, i, to)
end
:ok
end
@doc """
Returns values of all counters in a map. Counters with 0 value
or unitialized counters are not returned.
## Example
iex> ODCounter.init(:requests)
iex> ODCounter.add(:requests, 200)
iex> ODCounter.add(:requests, 400, 5)
iex> ODCounter.to_map(:requests)
%{200 => 1, 400 => 5}
"""
@spec to_map(name()) :: %{counter() => integer()}
def to_map(name) do
{counters_ref, _} = :persistent_term.get({__MODULE__, name})
map =
Map.new(:odcounter_ct.all(name), fn {counter, index} ->
{counter, :counters.get(counters_ref, index)}
end)
map =
for {{:odcounter, ^name, counter}, index} <- :persistent_term.get(), into: map do
{counter, :counters.get(counters_ref, index)}
end
Map.reject(map, & match?({_, 0}, &1))
end
@type info :: %{
counters_info: %{size: non_neg_integer(), memory: non_neg_integer()},
current_max_index: pos_integer(),
compile_time_indexes: %{counter() => pos_integer()},
runtime_indexes: %{counter() => pos_integer()}
}
@doc """
Returns information which can be used for debugging the ODCounter behaviour
"""
@spec info(name()) :: info()
def info(name) do
{counters_ref, index_atomics_ref} = :persistent_term.get({__MODULE__, name})
max = :atomics.get(index_atomics_ref, 1)
runtime_indexes =
for {{:odcounter, ^name, counter}, index} <- :persistent_term.get(), into: %{} do
{counter, index}
end
%{
counters_info: :counters.info(counters_ref),
current_max_index: max,
compile_time_indexes: :odcounter_ct.all(name),
runtime_indexes: runtime_indexes
}
end
@doc """
Returns a counters reference
## Example
iex> ODCounter.init(:cats)
iex> ODCounter.add(:cats, :tuxedo, 2)
iex> counters = ODCounter.counters(:dogs)
iex> :counters.info(counters).size
1025
"""
@spec counters(name()) :: :counters.counters_ref()
def counters(name) do
{counters_ref, _index_atomics_ref} = :persistent_term.get({__MODULE__, name})
counters_ref
end
@doc false
def do_add(name, index, amount) do
{counters_ref, _} = :persistent_term.get({__MODULE__, name})
:counters.add(counters_ref, index, amount)
:ok
end
@doc false
def do_put(name, index, value) do
{counters_ref, _} = :persistent_term.get({__MODULE__, name})
:counters.put(counters_ref, index, value)
:ok
end
@doc false
def do_get(name, index) do
{counters_ref, _} = :persistent_term.get({__MODULE__, name})
:counters.get(counters_ref, index)
end
@doc false
def do_atoi(name, counter) do
with nil <- ct_atoi(name, counter) do
rt_atoi(name, counter)
end
end
defp rt_atoi(name, counter) do
key = {:odcounter, name, counter}
with [] <- :persistent_term.get(key, []) do
{counters_ref, index_atomics_ref} = :persistent_term.get({__MODULE__, name})
new_index = :atomics.add_get(index_atomics_ref, 1, 1)
if new_index >= :counters.info(counters_ref).size do
:atomics.sub(index_atomics_ref, 1, 1)
raise "Runtime counters overflow"
end
:persistent_term.put(key, new_index)
new_index
end
end
defp ct_atoi(name, counter) do
:odcounter_ct.atoi(name, counter)
end
## Compile-time
defp counter_tab_fname do
Path.join(Mix.Project.build_path(), "odcounter.tab")
end
defp init_agent do
case File.read(counter_tab_fname()) do
{:ok, encoded_term} -> :erlang.binary_to_term(encoded_term)
_ -> %{}
end
end
@doc false
def get_or_start_agent do
case Agent.start(fn -> init_agent() end, name: __MODULE__.Agent) do
{:ok, pid} -> pid
{:error, {:already_started, pid}} -> pid
end
end
defp get_or_create_index(agent, name, counter) do
Agent.get_and_update(agent, fn state ->
case state do
%{^name => %{^counter => index}} ->
{index, state}
%{^name => in_name} ->
index = map_size(in_name) + 1
state = Map.put(state, name, Map.put(in_name, counter, index))
File.write!(counter_tab_fname(), :erlang.term_to_binary(state))
{index, state}
state ->
index = 1
state = Map.put(state, name, %{counter => index})
File.write!(counter_tab_fname(), :erlang.term_to_binary(state))
{index, state}
end
end)
end
defp add_or_get_ct_counter(name, counter) do
agent = get_or_start_agent()
get_or_create_index(agent, name, counter)
end
@doc """
Returns an index of the key
## Example
iex> ODCounter.init(:dogs)
iex> ODCounter.add(:dogs, :chiuaua, 42)
iex> index = ODCounter.atoi(:dogs, :chiuaua)
iex> :counters.get(ODCounter.counters(:dogs), index)
42
"""
defmacro atoi(name, counter) do
name = Macro.expand(name, __CALLER__)
unless is_atom(name) do
raise CompileError,
file: __CALLER__.file,
line: __CALLER__.line,
description: "Name must be a compile-time atom. Got #{Code.format_string! Macro.to_string(name)}"
end
counter = Macro.expand(counter, __CALLER__)
case Macro.quoted_literal?(counter) do
true ->
prepared_caller_env = Code.env_for_eval(__CALLER__)
{counter, [], _} = Code.eval_quoted_with_env(counter, [], prepared_caller_env)
if __CALLER__.module != nil do
Module.put_attribute(__CALLER__.module, :after_compile, {__MODULE__, :c_hook})
end
index = add_or_get_ct_counter(name, counter)
index
false ->
quote do
unquote(__MODULE__).do_atoi(unquote(name), unquote(counter))
end
end
end
@doc """
Increases the value of the `counter`
## Example
iex> ODCounter.init(:things)
iex> ODCounter.add(:things, :some_thing) # Default is to add 1
iex> ODCounter.add(:things, :some_thing, 2)
iex> ODCounter.get(:things, :some_thing)
3
"""
defmacro add(name, counter, amount \\ 1) do
quote do
index = unquote(__MODULE__).atoi(unquote(name), unquote(counter))
unquote(__MODULE__).do_add(unquote(name), index, unquote(amount))
:ok
end
end
@doc """
Decreases the `counter` value by `amount`. It expands to `add(name, counter, -amount)`.
Refer to `add/3` for more information
"""
defmacro sub(name, counter, amount \\ 1) do
quote do
unquote(__MODULE__).add(unquote(name), unquote(counter), -unquote(amount))
end
end
@doc """
Sets the `counter` to the `value`
## Example
iex> ODCounter.init(:clock)
iex> ODCounter.put(:clock, :hours, 13)
iex> ODCounter.put(:clock, :minutes, 44)
iex> ODCounter.put(:clock, :seconds, 59)
iex> ODCounter.to_map(:clock)
%{hours: 13, minutes: 44, seconds: 59}
"""
defmacro put(name, counter, value) do
quote do
index = unquote(__MODULE__).atoi(unquote(name), unquote(counter))
unquote(__MODULE__).do_put(unquote(name), index, unquote(value))
:ok
end
end
@doc """
Gets the value of the counter. If the counter was never set, 0 is returned
## Example
iex> ODCounter.init(:animals)
iex> ODCounter.add(:animals, :cows)
iex> ODCounter.add(:animals, :cows, 5)
iex> ODCounter.get(:animals, :cows)
6
iex> ODCounter.get(:animals, :parrots)
0
"""
defmacro get(name, counter) do
quote do
index = unquote(__MODULE__).atoi(unquote(name), unquote(counter))
unquote(__MODULE__).do_get(unquote(name), index)
end
end
@doc false
def c_hook(_env, _bytecode) do
change_back? =
case Code.get_compiler_option(:ignore_module_conflict) do
true ->
false
false ->
Code.put_compiler_option(:ignore_module_conflict, true)
true
end
try do
defmodule :odcounter_ct do
@moduledoc false
agent = ODCounter.get_or_start_agent()
@all Agent.get(agent, &Function.identity/1)
@max Map.new(@all, fn {name, in_name} -> {name, Enum.max(Map.values(in_name), &>=/2, fn -> 0 end)} end)
# IO.inspect @all, label: :generating
for {name, in_name} <- @all do
@name name
@in_name in_name
def all(@name), do: @in_name
end
for {name, in_name} <- @all, {counter, index} <- in_name do
@name name
@counter counter
@index index
def atoi(@name, @counter), do: @index
end
def atoi(_, _), do: nil
def max(name) do
Map.get(@max, name, 0)
end
end
after
if change_back? do
Code.put_compiler_option(:ignore_module_conflict, false)
end
end
end
end