Registry (OTP)

architecture

Registry (OTP)

Definition

Registry is an OTP module included in Elixir's standard library that provides a decentralized, scalable process registry for name-based process lookup and dispatch. Unlike Erlang's built-in :global module (which requires cluster-wide coordination) or atom-based naming (which is limited by the atom table), Registry allows processes to be registered under arbitrary keys -- strings, tuples, integers, or any Elixir term -- enabling dynamic, runtime-determined process naming without the risk of atom table exhaustion. Registry supports both unique registrations (exactly one process per key, like a key-value store) and duplicate registrations (multiple processes per key, enabling PubSub-like fan-out patterns).

Registry achieves high concurrency by partitioning its internal state across multiple ETS tables, one per scheduler, so that concurrent lookups and registrations on different keys do not contend for the same lock. This partitioned design means that Registry scales linearly with the number of CPU cores, making it suitable for systems with thousands or millions of dynamically created processes. Registrations are automatically cleaned up when the registered process terminates, eliminating the need for manual deregistration and preventing stale entries -- a property that leverages the BEAM's process monitoring infrastructure.

The Registry module is commonly used in conjunction with Dynamic Supervisors through the "via tuple" pattern, where processes are started under a DynamicSupervisor and simultaneously registered in a Registry. This combination enables the core OTP pattern for managing pools of dynamically created, named processes -- each process can be looked up by its domain-specific key rather than its process identifier (PID), which changes on restart.

Context in Prismatic

The Prismatic Platform uses Registry extensively for managing its 434 dynamically spawned agent processes. When agents are created at runtime through DynamicSupervisor, they register under domain-specific keys in partitioned registries. This enables efficient O(1) lookup by agent type, domain, tier, or task identifier without maintaining external state or querying a central coordinator. The pattern is critical for the agent ecosystem where processes are frequently created, terminated, and restarted by supervisors.

The platform implements a pluggable registry backend through the Adapter Pattern with PrismaticSupervisor.Registry.Behaviour. In development and single-node production, the Registry.ETS backend uses Elixir's standard Registry module backed by local ETS tables. In distributed production with multiple cluster nodes, the Registry.Horde backend uses Horde's CRDT-based distributed registry, providing eventual consistency of process registrations across nodes. The backend is selected at configuration time without code changes, enabling transparent scaling from single-node to multi-node deployments.

Via Tuples: The Registration Pattern

The "via tuple" is the standard OTP mechanism for integrating Registry with GenServer, Agent, and other OTP behaviours:

defmodule PrismaticAgents.Worker do
  use GenServer

  @registry PrismaticAgents.Registry

  # Start with via tuple -- registers in Registry automatically
  @spec start_link(keyword()) :: GenServer.on_start()
  def start_link(opts) do
    agent_id = Keyword.fetch!(opts, :id)
    name = via_tuple(agent_id)
    GenServer.start_link(__MODULE__, opts, name: name)
  end

  # Lookup and call by agent_id -- no PID needed
  @spec get_status(String.t()) :: {:ok, map()} | {:error, :not_found}
  def get_status(agent_id) do
    case Registry.lookup(@registry, agent_id) do
      [{pid, _value}] -> GenServer.call(pid, :get_status)
      [] -> {:error, :not_found}
    end
  end

  # Via tuple encapsulates Registry lookup in process naming
  defp via_tuple(agent_id) do
    {:via, Registry, {@registry, agent_id}}
  end

  # GenServer callbacks
  def init(opts) do
    {:ok, %{id: opts[:id], tier: opts[:tier], started_at: DateTime.utc_now()}}
  end

  def handle_call(:get_status, _from, state) do
    {:reply, {:ok, state}, state}
  end
end

The via tuple {:via, Registry, {RegistryName, key}} tells the GenServer to use Registry for name resolution. When the process starts, it registers under key in RegistryName. When another process calls GenServer.call(via_tuple(key), msg), the Registry resolves the key to a PID transparently.

Registry Configuration

Registry is started as a supervised child process, typically in the application's supervision tree:

defmodule PrismaticAgents.Application do
  use Application

  def start(_type, _args) do
    children = [
      # Unique registry: one process per key
      {Registry, keys: :unique, name: PrismaticAgents.Registry, partitions: System.schedulers_online()},

      # Duplicate registry: multiple processes per key (for pub-sub)
      {Registry, keys: :duplicate, name: PrismaticAgents.EventRegistry, partitions: System.schedulers_online()},

      # Dynamic supervisor using the registry for process naming
      {DynamicSupervisor, name: PrismaticAgents.DynamicSupervisor, strategy: :one_for_one}
    ]

    Supervisor.start_link(children, strategy: :one_for_one)
  end
end
OptionValuesPurpose
keys:unique or :duplicateOne process per key vs. many processes per key
nameModule atomRegistry identifier for lookups
partitionsInteger (default: 1)Number of ETS partitions for concurrency
listenersList of PIDsProcesses notified on register/unregister events
metaKeyword listMetadata stored in the registry

Unique vs. Duplicate Registries

Registry supports two fundamentally different modes:

FeatureUnique RegistryDuplicate Registry
Key CardinalityExactly one process per keyMultiple processes per key
Primary UseProcess naming, singleton lookupFan-out dispatch, event routing
AnalogyKey-value storePub-sub topic subscriptions
LookupReturns single {pid, value}Returns list of [{pid, value}]
RegistrationFails if key already takenAlways succeeds
Prismatic UseAgent worker registrationEvent listener groups
# Unique: lookup single agent by ID
[{pid, _}] = Registry.lookup(PrismaticAgents.Registry, "agent-001")

# Duplicate: dispatch event to all listeners for a topic
Registry.dispatch(PrismaticAgents.EventRegistry, "security_alert", fn entries ->
  for {pid, _} <- entries do
    send(pid, {:alert, "Critical vulnerability detected"})
  end
end)

Partitioned Architecture

Registry partitions its internal ETS tables across schedulers for concurrent access:

Registry with partitions: 4

  Partition 0 (ETS Table):  key_hash % 4 == 0  -->  {key, pid, value}
  Partition 1 (ETS Table):  key_hash % 4 == 1  -->  {key, pid, value}
  Partition 2 (ETS Table):  key_hash % 4 == 2  -->  {key, pid, value}
  Partition 3 (ETS Table):  key_hash % 4 == 3  -->  {key, pid, value}

  Concurrent lookups on different partitions do not contend.
  Optimal partitions = System.schedulers_online()
PartitionsConcurrencyMemoryOptimal For
1Serial accessMinimalSmall registries (< 100 processes)
N (schedulers)Full concurrencyN * overheadLarge registries (1000+ processes)
> schedulersNo additional benefitWasted memoryNever recommended

Registry.ETS vs. Registry.Horde Backend

The Prismatic Platform abstracts the registry implementation behind a behaviour:

defmodule PrismaticSupervisor.Registry.Behaviour do
  @moduledoc "Behaviour for pluggable registry backends."

  @callback register(key :: term(), value :: term()) :: {:ok, pid()} | {:error, term()}
  @callback lookup(key :: term()) :: [{pid(), term()}]
  @callback unregister(key :: term()) :: :ok
  @callback keys() :: [term()]
  @callback count() :: non_neg_integer()
end
FeatureRegistry.ETS (Local)Registry.Horde (Distributed)
ScopeSingle BEAM nodeAll nodes in cluster
ConsistencyStrong (immediate)Eventually consistent
PerformanceO(1) lookup, microsecondsO(1) lookup, sub-millisecond
Failure ModeLost on node crashSurvives node failures (replicated)
Data StructureETS tablesdelta-CRDTs
Configurationconfig :prismatic, registry: :etsconfig :prismatic, registry: :horde
Use CaseDevelopment, single-nodeProduction, multi-node
# config/config.exs (development -- local ETS)
config :prismatic_supervisor, :registry_backend, PrismaticSupervisor.Registry.ETS

# config/prod.exs (production -- distributed Horde)
config :prismatic_supervisor, :registry_backend, PrismaticSupervisor.Registry.Horde

Dispatch Patterns

Registry supports efficient dispatch to registered processes:

# Dispatch to all processes registered under a key (duplicate registry)
Registry.dispatch(PrismaticAgents.EventRegistry, "domain:security", fn entries ->
  for {pid, metadata} <- entries do
    if metadata.tier in [:l1, :l2] do
      send(pid, {:security_event, event_data})
    end
  end
end)

# Select processes matching a pattern (unique registry)
# Find all agents in the "perimeter" domain
matches = Registry.select(PrismaticAgents.Registry, [
  {{:"$1", :"$2", %{domain: :perimeter}}, [], [{{:"$1", :"$2"}}]}
])

# Count registered processes
count = Registry.count(PrismaticAgents.Registry)
# => 434

# Count processes matching a key pattern
domain_count = Registry.count_match(PrismaticAgents.Registry, "perimeter:*", :_)

Process Lifecycle Integration

Registry integrates with the BEAM's process monitoring to provide automatic cleanup:

1. Process starts: GenServer.start_link(Worker, opts, name: {:via, Registry, {Reg, key}})
   --> Registry.register(Reg, key, value)
   --> Registry monitors the process

2. Process runs: Registry.lookup(Reg, key) --> [{pid, value}]

3. Process crashes: Supervisor detects crash
   --> Registry receives :DOWN monitor message
   --> Registry removes {key, pid} entry automatically
   --> No stale entries, no manual cleanup

4. Supervisor restarts: New process registers under same key
   --> Registry.register(Reg, key, new_value)
   --> Lookup returns new PID transparently

This lifecycle integration means that callers using via tuples automatically get the restarted process after a crash, without any explicit re-registration code.

Performance Characteristics

OperationTime ComplexityTypical Latency
lookup/2O(1) amortized< 1 microsecond
register/3O(1) amortized< 5 microseconds
unregister/2O(1) amortized< 5 microseconds
dispatch/3O(n) where n = matching entries< 100 microseconds for 100 entries
select/2O(n) full scanDepends on registry size
count/1O(partitions)< 10 microseconds
  • Dynamic Supervisor - Runtime process creation paired with Registry
  • Supervisor - Static supervision tree using registered names
  • Cluster - Multi-node deployment requiring distributed registry
  • Agent - Platform agents managed via Registry lookup
  • BEAM - VM providing process monitoring for automatic cleanup
  • Process Isolation - Each registered process runs independently
  • Message Passing - Communication between registered processes
  • PubSub - Higher-level broadcasting built on duplicate registries
  • Adapter Pattern - Pluggable registry backend pattern
  • Eventual Consistency - Horde registry consistency model

See Also


Connect & Contribute

Created by Tomáš Korcak (korczis) | Open Source under GHL