dgen_registry (DGen v0.4.0)

Copy Markdown View Source

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:

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

member_id()

-type member_id() :: {node(), atom()}.

presence_event()

-type presence_event() :: {joined | left, Name :: term(), Pid :: pid()}.

query()

-type query() :: {all, #{term() => term()}} | #{term() => term()}.

registry_opts()

-type registry_opts() :: #{atom() => term()}.

Functions

await_ready(Name)

-spec await_ready(Name :: atom()) -> ok | {error, timeout}.

Blocks until ready/1 holds, or 5s elapses. See await_ready/2.

await_ready(Name, Timeout)

-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.

delete(Name, Tenant)

-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.

elector_pid(Name)

-spec elector_pid(atom()) -> pid() | undefined.

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.

get_epoch(Name)

-spec get_epoch(Name :: atom()) -> non_neg_integer().

Returns the current leader epoch. Increments each time a new leader is elected.

get_leader(Name)

-spec get_leader(Name :: atom()) -> member_id() | undefined.

Returns the current leader member id, or undefined if no leader is elected.

get_members(Name)

-spec get_members(Name :: atom()) -> [member_id()].

Returns the list of all current member ids in the registry.

get_metadata/1

-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.

get_metadata_consistent/1

-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.

init/1

member_name(Name)

-spec member_name(atom()) -> atom().

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.

names_table(Name)

-spec names_table(atom()) -> atom().

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.

query/2

-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.

query_consistent/2

-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.

ready(Name)

-spec ready(Name :: atom()) -> boolean().

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).

register_name(Name, Pid)

-spec register_name({atom(), term()}, pid()) -> yes | no.

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.

register_name/3

-spec register_name({atom(), term()}, pid(), map()) -> yes | no.

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.

send(Name, Msg)

-spec send({atom(), term()}, term()) -> pid().

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.

set_metadata/2

-spec set_metadata({atom(), term()}, map()) -> ok | {error, not_registered | no_leader}.

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).

start_link(Name, Tenant)

-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.

start_link(Name, Tenant, Opts)

-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.

subscribe(RegistryName, SubId, Watch, Notify)

-spec subscribe(atom(), term(), query(), query()) -> ok | {error, empty_query}.

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.

subscriptions(RegistryName)

-spec subscriptions(atom()) -> #{term() => {query(), query()}}.

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.

unregister_name/1

-spec unregister_name({atom(), term()}) -> ok.

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).

unsubscribe(RegistryName, SubId)

-spec unsubscribe(atom(), term()) -> 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.

unsubscribe_all(RegistryName)

-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).

whereis_name/1

-spec whereis_name({atom(), term()}) -> pid() | undefined.

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.

whereis_name_consistent/1

-spec whereis_name_consistent({atom(), term()}) -> pid() | undefined.

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.