A distributed, cluster-aware process registry for Elixir with per-entry metadata.
PgRegistry provides a Registry-shaped API on top of a self-contained
Elixir port of Erlang's :pg extended with per-entry metadata. Entries
are replicated across the cluster through gossip-based eventually
consistent membership. The API supports duplicate and per-node unique
keys, per-process values, listener notifications, ETS-native match-spec
queries, and runtime subscriptions.
def deps do
[
{:pg_registry, "~> 0.4"}
]
end# In a supervision tree
children = [
{PgRegistry, :my_registry}
]
# Register a process via a :via tuple
GenServer.start_link(MyServer, arg,
name: {:via, PgRegistry, {:my_registry, :my_key}})
# Register the calling process directly with metadata
{:ok, _} = PgRegistry.register(:my_registry, :worker, %{role: :primary})
# Look up entries across the cluster
PgRegistry.lookup(:my_registry, :worker)
#=> [{#PID<0.123.0>, %{role: :primary}}]
PgRegistry.whereis_name({:my_registry, :my_key})
#=> #PID<0.456.0>PgRegistry is a duplicate-keyed, cluster-aware process registry.
Multiple processes can register under one key, entries are gossiped
between nodes automatically, and the API follows the shape of Elixir's
Registry.
Registry |
:pg |
:global |
Horde.Registry |
PgRegistry |
|
|---|---|---|---|---|---|
| Scope | one node | cluster | cluster | cluster | cluster |
| Underlying model | ETS + GenServer | GenServer + gossip | cluster-wide lock | delta-CRDT | GenServer + gossip |
| Cost per register | µs | µs + async broadcast | ms (cluster-wide lock) | µs + async CRDT delta | µs + async broadcast |
| Convergence model | n/a | eventual, gossip | synchronous, lock-based | eventual, CRDT merge | eventual, gossip |
| Net-split behaviour | n/a | diverges, converges on heal without conflict | collisions on heal resolved by user-supplied resolver; may kill processes | CRDT merge picks a winner; losing registrations are dropped | diverges, converges on heal without conflict |
| Duplicate keys | yes | yes (only mode) | no | no | yes (default) |
| Unique keys | yes (per-node) | no | yes (cluster-wide) | yes (cluster-wide) | yes (per-node only) |
| Per-process values | yes | no | no | yes | yes |
| Match-spec queries | yes (ETS-native) | no | no | yes | yes (ETS-native) |
| Listeners | yes | no | no | yes | yes |
| Part of OTP | yes | yes | yes | no | no |
| Interop with other Erlang/OTP apps | no | yes | yes | no | no |
| Requirement | Recommended |
|---|---|
Cluster-wide process groups with values and a Registry-shaped API |
PgRegistry |
Cluster-wide process groups (pids only), or interop with existing :pg scopes |
:pg |
| Exactly one process per name, cluster-wide | Horde.Registry |
| Single-node registry with metadata and match-specs | Registry |
| Cluster-wide name uniqueness with strong consistency | :global |
| Both cluster-wide singletons and cluster-wide groups in the same application | Horde.Registry and PgRegistry together |
Three start-link forms are accepted:
# Bare scope name
{PgRegistry, :my_registry}
# Scope with options
{PgRegistry, {:my_registry, listeners: [MyListener]}}
# Keyword form (same option names as Registry.start_link/1)
{PgRegistry, name: :my_registry, listeners: [MyListener], keys: :duplicate}Supported options:
:listeners- a list of locally-registered process names that receive{:register, scope, key, pid, value}and{:unregister, scope, key, pid}messages on join/leave events.:keys-:duplicate(default) or:unique. In:uniquemode, uniqueness is enforced per-node only; see Per-node uniqueness below.:partitions- accepted for compatibility withRegistry. Only the value1is supported; other values raiseArgumentError. See Partitions below.
# Using a :via tuple (compatible with GenServer, Agent, and Task names)
{:via, PgRegistry, {scope, key}} # value defaults to nil
{:via, PgRegistry, {scope, key, value}} # 3-tuple attaches a value
# Using the self()-based API
PgRegistry.register(scope, key, value) #=> {:ok, self()}
PgRegistry.unregister(scope, key) #=> :ok
# Using the explicit-pid API
PgRegistry.register_name({scope, key}, pid)
PgRegistry.register_name({scope, key, value}, pid)
PgRegistry.unregister_name({scope, key})As with
Registry,:viatuple registration succeeds in:duplicatemode, but name resolution (whereis_name/1,send/2, and by extensionGenServer.call/3) raisesArgumentErrorbecause a duplicate-keyed scope may have many pids under one key. Uselookup/2ordispatch/3to address members instead.
A process may register multiple times under the same key with different values. Each registration is independent and must be unregistered separately. When a registered process exits, all of its entries are automatically removed and listeners and subscribers are notified.
PgRegistry.lookup(scope, key) # [{pid, value}, ...] (cluster-wide)
PgRegistry.lookup_local(scope, key) # [{pid, value}, ...] (local node only)
PgRegistry.values(scope, key, pid) # [value, ...] for one pid
PgRegistry.keys(scope, pid) # [key, ...] for one pid
PgRegistry.which_groups(scope) # [key, ...]
PgRegistry.count(scope) # total entriesTo extract pids without values:
for {pid, _} <- PgRegistry.lookup(scope, key), do: pidAll read functions operate directly against ETS from the calling process. They do not go through the scope GenServer.
lookup_local/2 has no equivalent in Registry. It returns only
entries whose pid is on the local node, which is useful for draining a
node before shutdown, collecting per-node metrics, or preferring a
local process before falling back to a remote one.
PgRegistry.update_value(scope, key, new_value) # updates self()'s entries
PgRegistry.update_value(scope, key, pid, new_value) # updates a specific pid's entriesUpdates every entry under key whose pid matches. Returns
:not_joined if the pid has no entry under the key. Subscribers
receive {ref, :update, key, [{pid, old, new}]} events. Listeners
do not receive update events, matching Registry's behaviour.
PgRegistry.match(scope, key, pattern)
PgRegistry.match(scope, key, pattern, guards)
PgRegistry.count_match(scope, key, pattern)
PgRegistry.count_match(scope, key, pattern, guards)
PgRegistry.unregister_match(scope, key, pattern)
PgRegistry.unregister_match(scope, key, pattern, guards)
PgRegistry.select(scope, match_spec)
PgRegistry.count_select(scope, match_spec)match/3,4 matches against the value position. select/2 accepts a
full ETS match-spec whose patterns are shaped as {key, pid, value},
the same shape used by Registry.select/2. All of these execute as
native ETS queries against the underlying table.
There are two mechanisms for reacting to scope changes.
Listeners are configured at scope start-up and receive messages
matching Registry's listener contract:
{PgRegistry, name: :my_registry, listeners: [MyListener]}
# MyListener receives:
{:register, :my_registry, key, pid, value}
{:unregister, :my_registry, key, pid}Listeners are addressed by registered name (atom). A listener that
crashes and restarts under the same name continues to receive events.
Listeners do not fire on update_value, matching Registry.
Runtime subscriptions are dynamic and ref-based. They also
deliver :update events:
{ref, snapshot} = PgRegistry.Pg.monitor_scope(:my_registry)
# snapshot :: %{key => [{pid, value}, ...]}
# The subscriber receives:
{^ref, :join, key, [{pid, value}, ...]}
{^ref, :leave, key, [{pid, value}, ...]}
{^ref, :update, key, [{pid, old, new}, ...]}
PgRegistry.Pg.demonitor(:my_registry, ref)Listeners are suited to fixed system-level integrations such as logging or metrics. Subscriptions are suited to consumers that start and stop dynamically.
PgRegistry.put_meta(scope, :config, %{retries: 3})
PgRegistry.meta(scope, :config) #=> {:ok, %{retries: 3}}
PgRegistry.delete_meta(scope, :config)Scope metadata is local to the node and is not gossiped. It is stored in a sibling ETS table for lock-free reads.
PgRegistry.dispatch(scope, key, fn members ->
for pid <- members, do: send(pid, {:work, payload})
end)Invokes the callback with the list of pids registered under key.
If no processes are registered, the callback is not invoked.
PgRegistry supports keys: :unique, but uniqueness is enforced
per-node, not cluster-wide. This follows the same scope as
Registry's :unique mode, extended to a distributed setting:
- On a single node, only one pid can hold a given key. A second
register/3returns{:error, {:already_registered, holder}}. - Across the cluster, each node may independently hold the same key. There is no cross-node arbitration.
{PgRegistry, name: :singletons, keys: :unique}
# On node A:
{:ok, _} = PgRegistry.register(:singletons, :worker, :v)
# Same node, second call:
{:error, {:already_registered, ^pid_a}} =
PgRegistry.register(:singletons, :worker, :v)
# Node B succeeds independently:
{:ok, _} = PgRegistry.register(:singletons, :worker, :v)The :via tuple integrates with this: register_name/2 returns :no
on collision, so GenServer.start_link(name: {:via, PgRegistry, ...})
surfaces {:error, {:already_started, pid}}.
When the holding process exits or calls unregister/2, the key
becomes available again on that node.
Per-node uniqueness is appropriate for patterns such as one connection
pool per node or one cache per node. For cluster-wide singletons, use
:global or a leader election library.
Multi-pid joins (Pg.join(scope, key, [p1, p2])) raise
ArgumentError in :unique mode because at most one pid can hold a
unique key.
PgRegistry uses a single ETS table per scope. Registry's
:partitions option shards the local table to reduce write contention;
in distributed workloads the dominant cost is gossip and convergence,
not local writes, so partitioning provides less benefit. PgRegistry
accepts partitions: 1 for compatibility and raises on other values.
If local write contention on a single scope becomes a bottleneck, splitting the scope into multiple scopes (one per logical workload) is the recommended approach.
PgRegistry inherits :pg's eventually consistent semantics. During a
network partition, each side continues to accept joins independently.
When the cluster heals, both sides resync through gossip and converge
without conflict, since duplicate entries are the expected state.
Limitation: on netsplit recovery, sync-driven membership changes
update ETS correctly but do not fire :update notifications for
entries whose metadata changed during the split. Subscribers observe
correct state on every read; only the notification stream during
convergence is incomplete. See the comment on sync_one_group/4 in
lib/pg_registry/pg.ex for details.
Each scope owns an ETS :duplicate_bag of rows shaped:
{key, pid, value, tag}
tag is an opaque per-node monotonic integer that gives every entry
its own identity. Tags are not exposed through the public API. They
allow ref-counted multi-join semantics to survive a flat-row layout and
enable cross-node leaves to identify a specific entry unambiguously,
even when {key, pid, value} would otherwise collide.
This layout allows match-spec queries (select/2, match/3) to run
as native ETS operations. User-supplied match-specs against
{key, pid, value} are translated to the 4-tuple storage shape by
appending :_ for the tag.
MIT. See LICENSE.