libring - A fast consistent hash ring for Elixir
This library implements a consistent hash ring in Elixir, with no external runtime
dependencies. Historical benchmarks compared it favorably with
voicelayer/hash-ring and
sile/hash_ring; use bench/ring.exs to assess
performance for your own workload and runtime.
Like libketama, the ring distributes virtual nodes
(shards) through a keyspace. Shard positions use :erlang.phash2/2 with range
2^32 - 1, producing values from 0 through 2^32 - 2. Placement is deterministic.
Lookup uses the same hash range. Atom keys are first serialized with
:erlang.term_to_binary/1; other terms are hashed directly. The owner is the node
at the first shard at or above the key's hash, wrapping to the smallest shard when
necessary. Shards are stored in Erlang's balanced :gb_trees structure.
Usage
Add :libring to your deps, and run mix deps.get.
def deps do
[
{:libring, "~> 1.0"}
]
end
You have two choices for managing hash rings in your application:
HashRing
This API works with the raw ring data structure. It is the fastest implementation, and is best suited for when you have a single process which will need to access the ring, and which can hold the ring in its internal state.
ring = HashRing.new()
|> HashRing.add_node("a")
|> HashRing.add_node("b")
"a" = HashRing.key_to_node(ring, {:myworker, 123})
You can also specify the weight of each node, and add nodes in bulk:
ring = HashRing.new()
|> HashRing.add_nodes(["a", {"b", 64}])
|> HashRing.add_node("c", 200)
"c" = HashRing.key_to_node(ring, {:myworker, 123})
NOTE: Node names do not have to be strings, they can be atoms, tuples, etc.
HashRing.key_to_nodes(ring, key, count) returns distinct owners in clockwise
order, starting with the same primary owner as key_to_node/2. For a populated
ring, the result contains the smaller of count and the number of nodes. Both
lookups return {:error, {:invalid_ring, :no_nodes}} for an empty ring. Counts must
be positive integers.
HashRing.Managed
Managed rings keep their data in an ETS table owned by a GenServer. Writes go through that server; reads using a ring name access ETS directly, while reads using a PID go through the server. This supports shared access and multiple named rings. The raw API avoids shared-process and table overhead when a caller can keep the ring itself.
Dynamic creation through new/2 requires the :libring application to be started.
{:ok, pid} = HashRing.Managed.new(:myring)
:ok = HashRing.Managed.add_node(:myring, "a")
:ok = HashRing.Managed.add_node(:myring, "b", 64)
:ok = HashRing.Managed.add_node(:myring, "c", 200)
"c" = HashRing.Managed.key_to_node(:myring, {:myworker, 123})
new/2 creates a worker under libring's dynamic supervisor. To give your own
supervisor ownership of restart and shutdown, use a child specification instead:
children = [
{HashRing.Managed, name: :myring, nodes: ["a", {"b", 64}]}
]
Supervisor.start_link(children, strategy: :one_for_one)
The child specification defaults to name/id :hash_ring_manager and supports
normal supervisor overrides such as :id, :restart, and :shutdown. This linked
startup route does not depend on libring's global dynamic supervisor. Startup
options must be keyword lists; invalid options raise ArgumentError before a
worker is started. A duplicate dynamically created name returns
{:error, {:already_started, pid}}.
Managed bulk/configured node lists interpret every two-tuple as a weighted entry.
Invalid weights return an error for bulk addition without changing membership.
For a tuple node key, use an explicitly weighted entry such as
{{:region, :node}, 128}. Other unambiguous terms are accepted directly. Empty
binary node keys are rejected with ArgumentError before mutation.
You can configure managed rings in config.exs, and they will be created and initialized
when the :libring application starts. Configured rings take two shapes, static and dynamic
rings. Static rings are simply those where the nodes are provided up front, although you can
always add/remove nodes manually at runtime; dynamic rings have Erlang node monitoring enabled,
and add or remove nodes on the ring based on cluster membership. On monitored
rings, valid manual add/remove requests return :ok without changing membership;
the nodes option seeds only static rings.
node_type selects remote peers (:visible, :hidden, or :all) consistently
at startup and afterward. The local node is also included unless filtered out.
Monitoring reflects Erlang connectivity, which does not guarantee that a peer's
applications or services are ready to receive work. Applications needing readiness
admission can maintain a static ring and add/remove nodes after their own checks.
You can whitelist/blacklist nodes when using dynamic rings, so that only those nodes which you actually want to distribute work to are used in calculations. This configuration is shown below as well.
If you provide a whitelist, the blacklist will have no effect, and only nodes matching the whitelist
will be added. If you do not provide a whitelist, the blacklist will be used to filter nodes. If you
do not provide either, the default blacklist excludes ~r/^remsh.*$/ and
~r/^rem-.*$/ to keep common remote-shell node names from changing the ring.
The whitelist and blacklist only have an effect when monitor_nodes: true.
Configuration
Below is an example configuration:
config :libring,
rings: [
# A ring which automatically changes based on Erlang cluster membership,
# but does not allow nodes named "a" or "remsh*" to be added to the ring
ring_a: [monitor_nodes: true,
node_type: :visible,
node_blacklist: ["a", ~r/^remsh.*$/, ~r/^rem-.*$/]],
# A ring which is composed of three nodes, of which "c" has a non-default weight of 200
# The default weight is 128
ring_b: [nodes: ["a", "b", {"c", 200}]]
]
Contributing
If you have changes in mind that are significant or potentially time-consuming, please open an RFC-style PR first, where we can discuss your plans first. I don't want you to spend all your time crafting a PR that I ultimately reject because I don't think it's a good fit or is too large for me to review. Not that I plan to reject PRs in general, but I have to be careful to balance features with maintenance burden, or I will quickly be unable to manage the project.
Please ensure that you adhere to a commit style where logically related changes are in a single commit, or broken up in a way that eases review if necessary. Keep commit subject lines informative, but short, and provide additional detail in the extended message text if needed. If you can, mention relevant issue numbers in either the subject or the extended message.
CI and local checks
Consumer compatibility remains Elixir 1.11 and later. CI checks the exact minimum,
Elixir 1.11.0 / OTP 23.1.1, in a pinned container with a dependency-free smoke suite.
Development tools have a newer minimum; the full suite runs on Elixir 1.17.3 /
OTP 27.3.4.19 and Elixir 1.20.5 / OTP 29.1.2. Use mise to install and select
alternate local runtimes without changing your global defaults, for example:
mise exec erlang@29.1.2 elixir@1.20.5 -- mix test
On the current development pair, run:
mix local.hex 2.6.0 --force
mix deps.get --check-locked
mix format --check-formatted
MIX_ENV=test mix deps.compile
MIX_ENV=test mix compile --warnings-as-errors
mix test
mix dialyzer
MIX_ENV=docs mix deps.get --only docs --check-locked
mix docs --warnings-as-errors
mix deps.unlock --check-unused
Normal verification uses the checked-in dependency lock; dependency updates should
be deliberate changes. CI restores caches for the exact runtime/environment and
saves them only after successful default-branch pushes. Dialyzer's PLTs live in
_build/plts, separate from global developer state.
Workflow checks use actionlint 1.7.12, zizmor 1.31.1, and gh-actions-lock v0.4.0:
actionlint -color
zizmor .github
gh actions-lock --verify-local
gh actions-lock --verify
Install the GitHub CLI extension with
gh extension install github/gh-actions-lock --pin v0.4.0 (upgrade an existing
installation to that version if necessary). Online lock verification needs an
authenticated gh; the offline check verifies coverage, not provenance.
Actions are SHA-pinned, with release comments for review, and native
.github/workflows/actions.lock also locks their transitive dependencies. After
changing a workflow's action versions, run gh actions-lock --no-narrow, then
review the workflow and lockfile diffs and rerun both analyzers. The --no-narrow
option preserves SHA references rather than rewriting them to tags. No zizmor
pinning exceptions are needed. Never automate --accept-moved; investigate
unexpected upstream changes. Container images and shell-installed tools are
pinned separately because the action lock does not cover them.
Dependabot groups weekly action and Hex dependency updates with a seven-day cooldown. When updating shell-installed tools or the legacy container/Hex archive, update their versions and verified checksums/digests together. The legacy job checks out on a modern host and runs with read-only source and no container network access; it does not share the modern toolchain cache.
Releases
Record user-visible changes under Unreleased in CHANGELOG.md,
including routing or lifecycle compatibility notes. Before publishing, select the
new version, update mix.exs, and move those notes into its release section.
Use jj to create a matching release tag without a v prefix, and ensure the
reviewed release commit and tag are available on GitHub before publishing docs.
Local development docs link to main. Set DOCS_SOURCE_REF to the release tag
when building release documentation; CI supplies its tested commit SHA. Review
the generated source links, run mix hex.build, and inspect the package contents
before publishing. Builds and checks do not publish a release. The Hex package
includes the changelog and library sources, excluding local task documents.
Copyright and License
Copyright (c) 2016 Paul Schoenfelder
This library is MIT licensed. See the LICENSE for details.