# PgRegistry
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.
## Installation
```elixir
def deps do
[
{:pg_registry, "~> 0.4"}
]
end
```
## Quick start
```elixir
# 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>
```
## Comparison
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 |
### Choosing a registry
| 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 |
## API
### Configuration
Three start-link forms are accepted:
```elixir
# 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 `:unique` mode,
uniqueness is enforced per-node only; see
[Per-node uniqueness](#per-node-uniqueness) below.
- `:partitions` - accepted for compatibility with `Registry`. Only the
value `1` is supported; other values raise `ArgumentError`. See
[Partitions](#partitions) below.
### Registering processes
```elixir
# 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})
```
> #### `:via` tuples in `:duplicate` mode {: .info}
>
> As with `Registry`, `:via` tuple registration succeeds in
> `:duplicate` mode, but name resolution (`whereis_name/1`, `send/2`,
> and by extension `GenServer.call/3`) raises `ArgumentError` because
> a duplicate-keyed scope may have many pids under one key. Use
> `lookup/2` or `dispatch/3` to 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.
### Reading
```elixir
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 entries
```
To extract pids without values:
```elixir
for {pid, _} <- PgRegistry.lookup(scope, key), do: pid
```
All 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.
### Updating values
```elixir
PgRegistry.update_value(scope, key, new_value) # updates self()'s entries
PgRegistry.update_value(scope, key, pid, new_value) # updates a specific pid's entries
```
Updates 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.
### Match-spec queries
```elixir
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.
### Subscriptions and listeners
There are two mechanisms for reacting to scope changes.
**Listeners** are configured at scope start-up and receive messages
matching `Registry`'s listener contract:
```elixir
{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:
```elixir
{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.
### Scope-level metadata
```elixir
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.
### Dispatch
```elixir
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.
## Design notes
### Per-node uniqueness
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/3` returns `{:error, {:already_registered, holder}}`.
- Across the cluster, each node may independently hold the same key.
There is no cross-node arbitration.
```elixir
{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.
### Partitions
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.
### Convergence and net-splits
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.
### Storage layout
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.
## License
MIT. See [LICENSE](LICENSE).