Current section
Files
Jump to
Current section
Files
lib/hrw.ex
defmodule HRW do
@moduledoc """
HRW (Highest Random Weight), also known as rendezvous hashing, maps a key
to a node out of a set in a way that stays stable when nodes are added or
removed.
This module is stateless. For O(log n) lookups over large node sets, build
a skeleton with `build/2` and pass it to `owner/3`.
"""
@behaviour HRW.Scorer
defstruct [:hash_fn]
@type t :: %__MODULE__{hash_fn: (term() -> integer()) | nil}
@doc """
Default scorer. Hashes `{key, node}` with the struct's `hash_fn`, falling
back to `:erlang.phash2/1` when `hash_fn` is `nil`.
Implements `HRW.Scorer`. Called internally whenever the default scorer is
selected (no `:scorer` option, or `scorer: %HRW{}` passed explicitly).
"""
@impl HRW.Scorer
@spec score(t(), term(), term()) :: integer()
def score(%__MODULE__{hash_fn: nil}, key, node), do: :erlang.phash2({key, node})
def score(%__MODULE__{hash_fn: hash_fn}, key, node), do: hash_fn.({key, node})
@doc """
Returns the node responsible for `key`.
Each node is hashed together with the key; the highest-scoring node wins.
## Options
* `:scorer` - scoring strategy struct. Defaults to `%HRW{}`. Ignored when
the second argument is a skeleton — pass `:scorer` to `build/2` instead.
## Examples
iex> HRW.owner("192.168.0.1", ["server1", "server2", "server3"])
"server2"
iex> skeleton = HRW.build(["server1", "server2", "server3"])
iex> HRW.owner("192.168.0.2", skeleton)
"server3"
"""
@spec owner(term(), [term()] | HRW.Skeleton.t(), keyword()) :: term()
def owner(key, nodes_or_skeleton, opts \\ [])
def owner(key, %HRW.Skeleton{} = skeleton, _opts) do
result = HRW.Skeleton.owner(key, skeleton)
unwrap_node(result, skeleton.scorer)
end
def owner(key, nodes, opts) do
validate_nodes(nodes, Keyword.get(opts, :scorer, %HRW{}))
nodes =
nodes
|> Enum.sort()
|> Enum.dedup()
if scorer = Keyword.get(opts, :scorer) do
%mod{} = scorer
nodes
|> Enum.max_by(fn node ->
mod.score(scorer, key, node)
end)
|> unwrap_node(scorer)
else
Enum.max_by(nodes, fn node ->
:erlang.phash2({key, node})
end)
end
end
@doc """
Returns the top `count` nodes responsible for `key`, in descending weight order.
## Options
* `:scorer` - scoring strategy struct. Defaults to `%HRW{}`.
## Examples
iex> HRW.owners("192.168.0.1", ["server1", "server2", "server3"], 2)
["server2", "server3"]
"""
@spec owners(term(), [term()], non_neg_integer(), keyword()) :: [term()]
def owners(key, nodes, count, opts \\ []) do
validate_nodes(nodes, Keyword.get(opts, :scorer, %HRW{}))
nodes =
nodes
|> Enum.sort()
|> Enum.dedup()
if scorer = Keyword.get(opts, :scorer) do
%mod{} = scorer
nodes
|> Enum.sort_by(fn node -> mod.score(scorer, key, node) end, :desc)
|> Enum.take(count)
|> Enum.map(&unwrap_node(&1, scorer))
else
nodes
|> Enum.sort_by(fn node -> :erlang.phash2({key, node}) end, :desc)
|> Enum.take(count)
end
end
@doc """
Builds a skeleton from `nodes` for O(log n) lookups.
Pass the result to `owner/3`.
## Options
* `:fanout` - branching factor of the virtual tree. Defaults to `3`.
* `:cluster_size` - target number of nodes per cluster. Defaults to `16`.
* `:scorer` - scoring strategy struct. Defaults to `%HRW{}`.
## Examples
iex> HRW.build(["server1", "server2", "server3"], fanout: 3)
#HRW.Skeleton<3 nodes, fanout: 3, scorer: %HRW{hash_fn: nil}>
"""
@spec build([term()], keyword()) :: HRW.Skeleton.t()
def build(nodes, opts \\ []) do
validate_nodes(nodes, Keyword.get(opts, :scorer, %HRW{}))
HRW.Skeleton.build(nodes, opts)
end
defp unwrap_node({node, _weight}, %HRW.Weighted{}), do: node
defp unwrap_node(node, _scorer), do: node
defp validate_nodes(nodes, %HRW{}) when is_list(nodes), do: nil
defp validate_nodes(_nodes, %HRW{}), do: raise(ArgumentError, "nodes must be a list")
defp validate_nodes(nodes, %HRW.Weighted{}) when is_list(nodes) do
if not Enum.all?(nodes, fn
{_node, weight} when is_number(weight) and weight > 0 -> true
_ -> false
end) do
raise ArgumentError,
"HRW.Weighted requires a list of {node, weight} tuples with positive numeric weights"
end
end
defp validate_nodes(_nodes, %HRW.Weighted{}),
do: raise(ArgumentError, "nodes must be a list of tuples")
end