OTP-compatible process registry implementing the {via, dgen_registry, {RegistryName, LogicalName}} contract.
Standard OTP processes (gen_server, gen_statem, gen_event, etc.) can be registered
and addressed by name across an Erlang cluster.
See docs/dgen_registry_design.md for the full design, guarantees, and non-goals;
section references (e.g. §5.7) in the registry modules point there.
This module implements the four-function contract required for OTP via tuples:
register_name/2— consistent write through the leaderunregister_name/1— consistent delete through the leaderwhereis_name/1— lock-free snapshot read from the local member's ETS tablesend/2— routes a message to a named process
Name terms
Every via name is a two-tuple {RegistryName, LogicalName} where
RegistryName identifies which registry to use (the one started with
start_link/2), and LogicalName is any term meaningful to the
application (atom, binary, tuple, …).
Example
{ok, _} = dgen_registry:start_link(my_registry, Tenant),
%% Start a gen_server registered in the dgen registry
gen_server:start_link({via, dgen_registry, {my_registry, user_service}},
my_server, [], []),
%% Call it from anywhere on the cluster
gen_server:call({via, dgen_registry, {my_registry, user_service}}, ping).Process identity
Starting a registry creates zero atoms. Name is supplied by the caller and is
application-controlled, not generated by this module. The top-level supervisor, the
elector, and the connector are never registered under a name — the supervisor is
addressed by the pid start_link/2,3 returns, and the elector is discovered at
startup (its $ancestors process-dictionary entry, then
supervisor:which_children/1) and thereafter held as a pid by the member and the
connector. Only the member is locally registered, under Name itself — the same
atom also names its ETS table (names_table/1); no additional atom is derived from
it. This matters for applications that start many independent registries dynamically
(e.g. one per tenant): atoms are never garbage-collected, so deriving additional
atoms per registry (as earlier versions of this module did) would grow the atom table
without bound. See §4.8 of the design doc.
Consistency model
All writes (register_name, unregister_name) and consistent reads
(whereis_name_consistent) go through the elected leader member process.
The leader is the single writer for the name table.
whereis_name/1 (used by the OTP via-tuple machinery) is a snapshot read
served lock-free from the local member's protected ETS table, in the calling
process — no member-mailbox hop, no network hop, no backend round-trip. The
table is kept in sync by one-way replication casts from the leader. There is
therefore a short window after registration where a remote node's
whereis_name/1 may still return undefined; this is the same
eventual-consistency trade-off as gproc global mode.
Leadership
The elected leader is the member process on the Erlang node that most recently committed a backend transaction for the elector queue — i.e., whoever wins the backend consensus. When leadership changes, the new leader gathers the freshest names map from every reachable member and broadcasts a snapshot to all followers on assumption. No distributed lock is used: the leader-key commit fences the old leader, making the handoff window naturally quiescent (§5.7 of the design doc).
Auto-unregistration
The leader monitors every registered Pid. When a monitored process exits,
the leader removes the entry and propagates {name_unregistered} to all
follower members.
Metadata and queries
A registration can carry metadata for its lifetime: register_name/3 /
set_metadata/2 attach an index map (queryable) and an opaque data payload;
get_metadata/1 / get_metadata_consistent/1 read it back (lock-free / leader-routed,
mirroring whereis_name/1 / whereis_name_consistent/1). query/2 /
query_consistent/2 find every registration whose index satisfies a conjunction of
exact equalities. See §4.7 of the design doc.
Presence
subscribe/4 / unsubscribe/2 turn a query into a live feed: a subscription watches
one query and notifies (via a second query) whenever the set of processes matching the
watched query changes — {dgen_presence, SubId, [{joined | left, Name, Pid}]}.
Subscriptions are durable (stored in the elector's backend state, keyed by an
application id), so they outlive the cluster — built for process-presence systems whose
stakeholders are database entities. See §4.9 of the design doc.
Summary
Functions
Blocks until ready/1 holds, or 5s elapses. See await_ready/2.
Blocks until the registry on this node is ready to serve registrations, or Timeout
(milliseconds) elapses.
Removes all of the registry's durable backend state. Returns ok.
Returns the current elector pid() for registry Name, or undefined if the
member (and so the elector) cannot currently be found.
Returns the current leader epoch. Increments each time a new leader is elected.
Returns the current leader member id, or undefined if no leader is elected.
Returns the list of all current member ids in the registry.
Snapshot read of a registration's metadata — served lock-free from the calling process.
Consistent metadata read routed through the leader (authoritative, never stale).
Returns the registered name of the member process for the given registry name.
Returns the ETS table name holding the registry's local Name => Pid replica.
Finds every registration whose indexed metadata satisfies all of Constraints.
Like query/2, but routed through the leader for an authoritative (never-stale) result.
Is the registry on this node ready to serve registrations right now?
Registers Pid under {RegistryName, LogicalName}.
Registers Pid under {RegistryName, LogicalName} with metadata, in one fenced step.
Sends Msg to the process registered as Name, returning the Pid.
Replaces the metadata of the existing registration {RegistryName, LogicalName}.
Starts the registry Name with default options. See start_link/3 for per-registry tuning.
Starts the registry Name with per-registry options Opts.
Creates (or updates) a durable presence subscription SubId: keeps the processes
matching the Notify query up to date with the set of processes matching the Watch
query.
Returns the registry's durable presence subscriptions as #{SubId => {Watch, Notify}}.
Removes the registration for {RegistryName, LogicalName}. Always returns ok.
Removes the durable presence subscription SubId (from subscribe/4). Returns ok
(idempotent — removing an unknown SubId is a no-op). Sent as a durable cast, applied
asynchronously (see subscribe/4). A missing registry exits.
Removes all durable presence subscriptions from the registry. Returns ok.
Snapshot read — served lock-free from the local member's ETS table.
Consistent read routed through the leader.
Types
Functions
-spec await_ready(Name :: atom()) -> ok | {error, timeout}.
Blocks until ready/1 holds, or 5s elapses. See await_ready/2.
-spec await_ready(Name :: atom(), Timeout :: non_neg_integer()) -> ok | {error, timeout}.
Blocks until the registry on this node is ready to serve registrations, or Timeout
(milliseconds) elapses.
Returns ok once ready/1 holds, or {error, timeout} if the deadline passes first.
Intended for a node that has just started/joined a cluster: it waits out leader
election and the join handoff (the leader gathers and delivers the names snapshot)
before the caller starts registering, so registrations are not fast-rejected against a
not-yet-known leader. Safe to call from many nodes; it only reads local member state.
-spec delete(atom(), dgen_backend:tenant()) -> ok.
Removes all of the registry's durable backend state. Returns ok.
Every key a registry writes — the elector's membership/leader/subscription state and its
message queue, plus the leader-fence and version keys — lives under a single tenant
keyspace prefix derived from Name, so this clears exactly that registry's keys (and no
other registry sharing the tenant). Use it to reclaim the durable footprint of a
registry you are done with — e.g. one of many short-lived, per-entity registries in a
shared tenant (see the "Process identity" note on why nothing else is left behind).
Stop the registry first. delete/2 operates directly on the backend and assumes no
live processes: a running elector caches its state in memory and would re-write it (and
so re-create the keys) on its next commit. The correct sequence is
Supervisor.stop(Sup) (the pid start_link/2,3 returned), then delete/2 with the
tenant you started the registry with.
This does not delete anything the registered processes themselves own; it only removes the registry's own bookkeeping.
Returns the current elector pid() for registry Name, or undefined if the
member (and so the elector) cannot currently be found.
The elector has no registered name — it is discovered by locating the member
(registered as Name), reading its $ancestors process-dictionary entry (set by
proc_lib at spawn time) to find the shared supervisor, then reading that
supervisor's children (supervisor:which_children/1) for the one with child id
elector. This is an all-local, in-memory operation (no network hop); it is what
get_leader/1, get_epoch/1, and get_members/1 use internally, and is exported
for introspection.
-spec get_epoch(Name :: atom()) -> non_neg_integer().
Returns the current leader epoch. Increments each time a new leader is elected.
Returns the current leader member id, or undefined if no leader is elected.
Returns the list of all current member ids in the registry.
-spec get_metadata({atom(), term()}) -> {ok, #{pid := pid(), index := map(), data := term()}} | undefined.
Snapshot read of a registration's metadata — served lock-free from the calling process.
Like whereis_name/1, this is an ets:lookup/2 against the local member's protected
table with no member round-trip. Returns {ok, #{pid := pid(), index := map(), data := term()}} for a registered name, or undefined if it is not registered (or the member's
table does not exist yet). May lag a recent change made on another node.
-spec get_metadata_consistent({atom(), term()}) -> {ok, #{pid := pid(), index := map(), data := term()}} | undefined.
Consistent metadata read routed through the leader (authoritative, never stale).
Returns the same shape as get_metadata/1. More expensive (one network hop) but
reflects the leader's committed view.
Returns the registered name of the member process for the given registry name.
Identity today (member_name(Name) =:= Name) — the member is registered under
Name directly. Kept as a function (rather than inlining Name at call sites) so
callers do not depend on that being the case.
Returns the ETS table name holding the registry's local Name => Pid replica.
Identity today (names_table(Name) =:= Name) — the member's names table is
named Name directly (ETS named_table names and Erlang process-registration
names are separate namespaces, so this does not collide with the member's own
registration). The table is owned and written by the member (a protected,
read_concurrency table) and read lock-free from any process — see
whereis_name/1.
-spec query(atom(), map()) -> [#{name := term(), pid := pid(), index := map(), data := term()}] | {error, empty_query}.
Finds every registration whose indexed metadata satisfies all of Constraints.
Constraints is #{attr() => value()} — a conjunction of exact equalities (AND-equal)
matched against each registration's index map. Returns a point-in-time list of
#{name, pid, index, data} matches (possibly empty), or {error, empty_query} if
Constraints is empty (use a future list/1 for "everything").
This is a batch-consistent snapshot read of the local member's replica: it runs on the member's mailbox (unlike the lock-free single-key reads) so it never observes a half-applied group commit, but it may still lag a change made on another node. A returned pid may already have died — treat results as candidates, not liveness guarantees.
-spec query_consistent(atom(), map()) -> [#{name := term(), pid := pid(), index := map(), data := term()}] | {error, empty_query}.
Like query/2, but routed through the leader for an authoritative (never-stale) result.
Returns the same shape as query/2. More expensive (it serialises on the leader's
mailbox, one network hop from a follower) but reflects the leader's committed view.
Is the registry on this node ready to serve registrations right now?
Returns true once the local member both knows a leader and has synced registry
state (applied the leader's snapshot, or assumed leadership itself). Returns false
while it is still starting/joining, has no elected leader, or is momentarily
unresponsive (a busy member is treated as not-ready rather than blocking the caller).
This is a point-in-time check; use await_ready/2 to block until ready. Note that
readiness means registrations are being served, not that any particular
registration will return yes (a name may already be taken, or leadership may move).
Registers Pid under {RegistryName, LogicalName}.
Routes through the local member, which forwards to the leader if needed.
Returns yes on success, no if the name is already held by a different
process or no leader is currently elected.
Registering the same pid under the same name again is an idempotent
yes (not a no): a caller whose register call timed out — but whose
registration in fact committed — can safely redrive it and get a decisive
yes for its own binding. See register_name/3 for the timeout contract.
Registers Pid under {RegistryName, LogicalName} with metadata, in one fenced step.
MetaSpec is #{index => map(), data => term()} (both optional; defaults #{} and
undefined). Like register_name/2, returns yes on success or no if the name is
already taken, no leader is currently elected, or the leader is unreachable — those
are all adjudicated verdicts, answered promptly.
A call timeout (the member, or the leader behind it, did not answer within
register_timeout, §8 — default 5000 ms) exits rather than answering no: a
timeout means no verdict exists, and masking it as no would tell the caller "the
name is taken" when the registration may in fact have committed. Letting the exit
propagate matches what global, syn, and other registries do — the caller (or its
supervisor) crashes and retries with fresh state. A missing registry (noproc) also
exits — a usage error.
Sends Msg to the process registered as Name, returning the Pid.
Exits with reason {badarg, {Name, Msg}} if the name is not registered.
Called internally by gen_server/gen_event for {via, …} routing.
Replaces the metadata of the existing registration {RegistryName, LogicalName}.
MetaSpec is #{index => map(), data => term()} (both optional; an omitted field
becomes #{} / undefined — this is a replace, not a merge). Routes through the leader
and rides the fenced commit. Returns ok, {error, not_registered} if the name is not
currently bound, or {error, no_leader} if no leader is elected (or leadership changed
mid-flight — retry).
-spec start_link(Name :: atom(), Tenant :: dgen_backend:tenant()) -> supervisor:startlink_ret().
Starts the registry Name with default options. See start_link/3 for per-registry tuning.
-spec start_link(Name :: atom(), Tenant :: dgen_backend:tenant(), Opts :: registry_opts()) -> supervisor:startlink_ret().
Starts the registry Name with per-registry options Opts.
Opts is a map of tuning knobs scoped to this registry — register_replicas,
replicate_timeout, strict_replication, terminate_on_conflict,
conflict_kill_budget, reject_when_degraded. Each unset key falls back to the
dgen application environment, then to a built-in default, so different registries
can be tuned independently. See docs/dgen_registry_design.md (§8 Configuration).
The supervisor itself is not registered under a name — hold onto the pid()
this returns (e.g. Supervisor.stop/2 to tear it down) rather than looking it up
later by Name, which now names the member, not the supervisor. See the
"Process identity" note above.
Creates (or updates) a durable presence subscription SubId: keeps the processes
matching the Notify query up to date with the set of processes matching the Watch
query.
Watch and Notify are query/0 values — a {all, #{attr => value}} conjunction of
exact equalities over registration index metadata (a bare map is accepted and read as
{all, Map}). SubId is any application term; a subscribe with an existing SubId is
an idempotent upsert (replaces its queries).
Whenever a committed batch of registrations changes which processes satisfy Watch — a
process registers into it, unregisters/dies out of it, or a metadata change moves it in
or out — the registry sends, to every process currently matching Notify, one
message:
{dgen_presence, SubId, Events}where Events is a non-empty list of presence_event/0 — {joined, Name, Pid} /
{left, Name, Pid}. Every process that matches Notify receives an initial snapshot
(the current Watch set as a batch of joined events) when it enters the notify set —
whether that is at subscribe time or when it registers into Notify later — and deltas
thereafter. A viewer therefore always has a complete, continuously-maintained view from
the moment it appears, so the subscription can safely be created before any viewer exists
(e.g. tied to a database entity's lifetime).
Durability
The subscription is stored in the elector's durable backend state, not tied to any
Erlang process: it outlives the cluster. An application can therefore tie a
subscription's lifetime to a database entity (subscribe when the entity is created,
unsubscribe/2 when it is deleted) and have presence come back intact after a
scale-to-zero — the durable subscription is reloaded and re-fed to the leader on
restart, and processes matching Notify resume receiving updates as they re-register.
Returns ok, or {error, empty_query} if either query is empty (validated locally).
The write is sent to the elector as a durable cast (fire-and-forget) for low latency,
so ok means "accepted for durable processing", not "already committed": it commits
asynchronously and subscriptions/1 reflects it shortly after. Durability still holds —
the change commits in the elector's backend transaction and survives a full cluster
restart — and because a subscription is an idempotent upsert keyed by SubId, a write
lost in the narrow window before the elector durably enqueues it is safely recoverable by
re-subscribing (the application owns the source of truth). A missing registry (noproc)
exits.
Returns the registry's durable presence subscriptions as #{SubId => {Watch, Notify}}.
A read of the elector's committed state (shared cluster-wide and durable), so it
reflects every subscription regardless of which node created it. Returns #{} if the
registry cannot currently be reached.
Removes the registration for {RegistryName, LogicalName}. Always returns ok.
Routes through the local member as a call (mirroring the register path): the
member removes the entry from its own replica immediately — snapshot reads on this
node see the delete at once — and forwards the removal to the leader as a tracked
request. ok normally means the leader has committed the removal; if the leader is
unreachable (or answers late), ok means the removal is stashed and re-driven —
forwarded to the current leader as a pid-guarded retract on the next leadership
change or rejoin snapshot — so an explicit unregister is never silently lost
(Non-goal 5 of the design doc). Cluster-wide visibility remains eventually
consistent (replication is asynchronous).
Removes the durable presence subscription SubId (from subscribe/4). Returns ok
(idempotent — removing an unknown SubId is a no-op). Sent as a durable cast, applied
asynchronously (see subscribe/4). A missing registry exits.
-spec unsubscribe_all(atom()) -> ok.
Removes all durable presence subscriptions from the registry. Returns ok.
Like unsubscribe/2 but wholesale — a durable cast, applied asynchronously, pushed to
the leader so it drops every subscription's live state. Useful for tearing down presence
without deleting the registry itself (a full delete/2 also removes them, along with
everything else).
Snapshot read — served lock-free from the local member's ETS table.
Runs entirely in the calling process (an ets:lookup/2 against the member's
protected names table), so it never blocks on the member's mailbox, the leader,
or the backend — many callers read concurrently. May be slightly stale on follower
nodes in the brief window between a remote registration and the replication cast
arriving, and returns undefined while the member is (re)starting and its table
does not yet exist.
Consistent read routed through the leader.
Returns the authoritative Pid for LogicalName, or undefined if not
registered. More expensive than whereis_name/1 but never stale.