Packages
Typed distributed messaging for Gleam on the BEAM.
Current section
Files
Jump to
Current section
Files
src/distribute.gleam
//// `distribute`: Typed messaging for the BEAM.
////
//// This library provides a safety layer over Erlang's native distribution.
//// It focuses on hardening the **Node Boundary**: the physical edge where
//// typed Gleam values become raw binary terms and vice versa.
////
//// ## The Typed Boundary Concept
////
//// In standard Erlang distribution, messages cross the wire as raw terms
//// without type information. `distribute` forces you to define a protocol
//// using `TypedName` and `Codec` before any data is sent.
////
//// 1. **At the Sender**: messages are encoded, checked against payload
//// limits, and transmitted via BEAM distribution.
//// 2. **At the Receiver**: binaries have their size verified *before*
//// decoding. If they exceed limits, they are rejected. If they are
//// valid, they are decoded into typed Gleam values.
////
//// This architecture ensures that an actor's mailbox is never flooded
//// with unparseable or oversized data, and the compiler can prevent
//// protocol mismatches at the call site.
////
//// ## Core Philosophy
////
//// - **Fail-Fast over Retry**: every failure (Timeout, TargetDown,
//// DecodeError) is surfaced immediately. Managing retries and circuit
//// breaking is left to the application layer.
//// - **Memory First**: payload limits are enforced at the I/O boundary
//// to protect nodes from OOM attacks or bugs.
//// - **OTP Native**: every part of the library (supervisors, monitors,
//// subjects) follows standard BEAM OTP patterns.
//// - **Zero Cost Read**: configuration is stored in Erlang's
//// `persistent_term` for O(1) reads with zero heap allocation.
////
//// ## Typical Usage
////
//// ```gleam
//// // 1. Configure once at application startup
//// let assert Ok(Nil) = distribute.configure(config.default())
////
//// // 2. Define a protocol (Name + Codec)
//// let counter = distribute.named("counter", codec.int())
////
//// // 3. Start a globally registered, supervised singleton
//// let assert Ok(pid) = distribute.start_supervised(counter, 0, handler)
////
//// // 4. Call from any node. Monitor-based TargetDown detection,
//// default timeout from config
//// let assert Ok(val) = distribute.call(gs, Get, codec.int_decoder())
//// ```
////
//// ## Default vs explicit timeout
////
//// Every long-running operation has two shapes:
////
//// - The **default form** reads from `config.get()`. This is the path
//// you want 90% of the time. A single `configure(...)` at boot tunes
//// the whole surface (`call`, `receive`, `start_actor`,
//// `start_registered`, `start_supervised`, `pool`, `child_spec`,
//// `call_isolated`).
//// - The **`_with_timeout` form** is the explicit override. Pick it
//// only when the request has a hard deadline that diverges from
//// `config.default_*`.
////
//// ## Error handling without extra imports
////
//// Every public error type (and its `*_to_string` formatter) is
//// re-exported here. Pattern-matching on `CallError`, `RegisterError`,
//// `LookupError`, etc., requires only `import distribute`.
////
//// ## Reply-Subject helpers live in `distribute/receiver`
////
//// Handlers that answer a `call` typically need `receiver.receive_typed`
//// or `receiver.selecting_typed` to operate on the raw reply-Subject.
//// These are intra-handler primitives: import the module directly.
////
//// ```gleam
//// import distribute/receiver
//// case receiver.receive_typed(reply_to, codec.int_decoder(), 1000) {
//// Ok(value) -> ...
//// Error(receiver.ReceiveTimeout) -> ...
//// Error(receiver.DecodeError(_)) -> ...
//// }
//// ```
////
//// ## API surface, by concern
////
//// - **Boot & config**: `configure`, `get_config`, `version`, `Config`,
//// `ConfigError`, `config_error_to_string`.
//// - **Node lifecycle**: `start_node`, `connect`, `nodes`, `self_node`,
//// `is_distributed`, `has_peers`, `health`, `ClusterHealth`,
//// `NodeStartError`, `ConnectError`,
//// `node_start_error_to_string`, `connect_error_to_string`.
//// - **Cluster events**: `start_monitor`, `subscribe`, `unsubscribe`,
//// `ClusterEvent`, `MonitorMessage`.
//// - **Protocol**: `named`, `new_subject`, `register`, `lookup`,
//// `unregister`, `unregister_typed`, `TypedName`, `GlobalSubject`,
//// `RegisterError`, `UnregisterError`, `LookupError`,
//// `register_error_to_string`, `unregister_error_to_string`,
//// `lookup_error_to_string`.
//// - **Actor lifecycle**: `start_actor` / `start_actor_with_timeout` /
//// `start_actor_observed`, `start_registered` /
//// `start_registered_with_timeout`, `start_supervised` /
//// `start_supervised_with_timeout`, `pool` / `pool_with_timeout`,
//// `child_spec` / `child_spec_with_timeout`, `HandlerStep`,
//// `ActorStartError`, `StartRegisteredError`,
//// `actor_start_error_to_string`, `start_registered_error_to_string`.
//// - **Messaging**: `send`, `reply`, `receive` / `receive_with_timeout`,
//// `call` / `call_with_timeout`, `call_isolated` /
//// `call_isolated_with_timeout`, `SendError`, `CallError`,
//// `ReceiveError`, `EncodeError`, `DecodeError`,
//// `send_error_to_string`, `call_error_to_string`,
//// `receive_error_to_string`, `encode_error_to_string`,
//// `decode_error_to_string`.
////
//// Most users only need `import distribute`. Low-level modules
//// (`distribute/actor`, `distribute/global`, `distribute/registry`,
//// `distribute/codec`, `distribute/receiver`, `distribute/cluster`,
//// `distribute/cluster_monitor`, `distribute/codec/composite`,
//// `distribute/codec/tagged`, `distribute/config`) remain available for
//// advanced cases not covered by the facade. e.g. `whereis`,
//// `register_pid`, `register_typed`, `from_pid`, `from_subject`,
//// `lookup_with_timeout`, `lookup_async`, `start_registered_observed`,
//// `receive_typed`, `selecting_typed`.
import distribute/actor as dist_actor
import distribute/cluster
import distribute/cluster_monitor
import distribute/codec
import distribute/config
import distribute/global
import distribute/receiver
import distribute/registry
import distribute/telemetry
import gleam/erlang/process
import gleam/otp/actor
import gleam/otp/supervision
// -- Boot & config -----------------------------------------------------------
/// Library version, hardcoded. Must stay in sync with `gleam.toml`
/// at release time. Gleam has no compile-time API to read the
/// manifest, so this is a release-time discipline, not a runtime
/// invariant.
pub fn version() -> String {
"4.0.1"
}
pub type Config =
config.Config
pub type ConfigError =
config.ConfigError
/// Set global runtime configuration. Call once at application startup.
pub fn configure(cfg: Config) -> Result(Nil, ConfigError) {
config.configure(cfg)
}
/// Read the current global configuration (or defaults if never configured).
pub fn get_config() -> Config {
config.get()
}
pub fn config_error_to_string(err: ConfigError) -> String {
config.config_error_to_string(err)
}
// -- Cluster -----------------------------------------------------------------
pub type NodeStartError =
cluster.StartError
pub type ConnectError =
cluster.ConnectError
pub type ClusterHealth =
cluster.ClusterHealth
/// Start a distributed BEAM node.
///
/// `name` must contain `@` (e.g. `"myapp@127.0.0.1"`).
/// `cookie` must be `[a-zA-Z0-9_-]+` and 1..255 bytes (validated by FFI).
pub fn start_node(name: String, cookie: String) -> Result(Nil, NodeStartError) {
cluster.start_node(name, cookie)
}
pub fn connect(node: String) -> Result(Nil, ConnectError) {
cluster.connect(node)
}
pub fn nodes() -> List(String) {
cluster.nodes()
}
pub fn self_node() -> String {
cluster.self_node()
}
/// Whether this node is running BEAM distribution (via `erlang:is_alive/0`).
pub fn is_distributed() -> Bool {
cluster.is_distributed()
}
/// Whether this node has at least one connected peer. *Not* a health
/// check. A single-node deployment is operationally fine and will
/// return `False` here. Use this only to gate cluster-wide operations.
pub fn has_peers() -> Bool {
cluster.has_peers()
}
/// Full cluster health snapshot with parallel pings.
pub fn health() -> ClusterHealth {
cluster.health()
}
pub fn node_start_error_to_string(err: NodeStartError) -> String {
cluster.start_error_to_string(err)
}
pub fn connect_error_to_string(err: ConnectError) -> String {
cluster.connect_error_to_string(err)
}
// -- Cluster events ----------------------------------------------------------
pub type ClusterEvent =
cluster_monitor.ClusterEvent
pub type MonitorMessage =
cluster_monitor.Message
/// Start the cluster monitor actor. It listens for Erlang node events
/// and broadcasts them to all Gleam subscribers.
pub fn start_monitor() -> Result(
process.Subject(MonitorMessage),
actor.StartError,
) {
cluster.start_monitor()
}
pub fn subscribe(
monitor: process.Subject(MonitorMessage),
listener: process.Subject(ClusterEvent),
) {
cluster.subscribe(monitor, listener)
}
pub fn unsubscribe(
monitor: process.Subject(MonitorMessage),
listener: process.Subject(ClusterEvent),
) {
cluster.unsubscribe(monitor, listener)
}
// -- Telemetry ---------------------------------------------------------------
pub type TelemetryEvent =
telemetry.Event
pub type TelemetrySink =
telemetry.EventSink
/// Install (or replace) the global telemetry sink for observability.
/// This single opt-in sink receives all load-bearing events from the library
/// (registry, atom budget, payload limits, codec failures, timeouts).
/// See `distribute/telemetry` for the full semantics and event structure.
pub fn install_telemetry(sink: TelemetrySink) -> Nil {
telemetry.install(sink)
}
// -- Handler signatures ------------------------------------------------------
pub type HandlerStep(state) =
receiver.HandlerStep(state)
// -- Protocol: named codecs and subjects -------------------------------------
pub type TypedName(msg) =
registry.TypedName(msg)
pub type GlobalSubject(msg) =
global.GlobalSubject(msg)
/// Create a `TypedName` from a bundled `Codec`.
///
/// ```gleam
/// let counter = distribute.named("counter", codec.int())
/// ```
pub fn named(name: String, c: codec.Codec(msg)) -> TypedName(msg) {
registry.named(name, c)
}
/// Create a new `GlobalSubject` owned by the current process, from a
/// bundled `Codec`. For separate encoder/decoder, drop down to
/// `global.new` directly.
pub fn new_subject(c: codec.Codec(msg)) -> GlobalSubject(msg) {
global.new(c.encoder, c.decoder)
}
// `unsafe_from_name` is intentionally not re-exported on the facade:
// reconstructing a `GlobalSubject` from a free-form (name, pid,
// codec) tuple bypasses the `TypedName` discipline that the rest of
// the API leans on. External callers should use `lookup` to obtain
// a Subject for a registered actor; that path threads the codec
// through the opaque `TypedName` and stays within the safety
// invariants. The function is still available on `distribute/global`
// as `@internal unsafe_from_name` for the registry, receiver, and
// actor modules.
// -- Protocol: registry ------------------------------------------------------
pub type RegisterError =
registry.RegisterError
pub type UnregisterError =
registry.UnregisterError
pub type LookupError =
registry.LookupError
/// Register a `GlobalSubject` under its `TypedName`. The typed pair is
/// the recommended path; for raw-PID registration import
/// `distribute/registry` and call `registry.register/2` directly.
pub fn register(
typed_name: TypedName(msg),
subject: GlobalSubject(msg),
) -> Result(Nil, RegisterError) {
registry.register_global(typed_name, subject)
}
/// Look up a `GlobalSubject` by its `TypedName`. For polling variants
/// (blocking and async) see `registry.lookup_with_timeout` /
/// `registry.lookup_async`.
pub fn lookup(typed_name: TypedName(msg)) -> Result(GlobalSubject(msg), Nil) {
registry.lookup(typed_name)
}
/// Unregister a globally registered name. Idempotent cleanup paths can
/// `let _ = unregister(name)`.
pub fn unregister(name: String) -> Result(Nil, UnregisterError) {
registry.unregister(name)
}
/// Type-safe sibling of `unregister/1`: pulls the name string from the
/// `TypedName` the caller already holds. Recommended for graceful
/// shutdown paths so the cleanup site never has to hardcode the name.
pub fn unregister_typed(
typed_name: TypedName(msg),
) -> Result(Nil, UnregisterError) {
registry.unregister_typed(typed_name)
}
pub fn register_error_to_string(err: RegisterError) -> String {
registry.register_error_to_string(err)
}
pub fn unregister_error_to_string(err: UnregisterError) -> String {
registry.unregister_error_to_string(err)
}
pub fn lookup_error_to_string(err: LookupError) -> String {
registry.lookup_error_to_string(err)
}
// -- Actors ------------------------------------------------------------------
pub type ActorStartError =
actor.StartError
pub type StartRegisteredError =
dist_actor.StartRegisteredError
/// Render a `gleam_otp/actor.StartError` as a human-readable string.
pub fn actor_start_error_to_string(err: ActorStartError) -> String {
case err {
actor.InitTimeout -> "Actor init timed out"
actor.InitFailed(reason) -> "Actor init failed: " <> reason
actor.InitExited(_) -> "Actor init process exited"
}
}
pub fn start_registered_error_to_string(err: StartRegisteredError) -> String {
dist_actor.start_registered_error_to_string(err)
}
/// Start a named actor using `config.get().default_init_timeout_ms`.
/// Use `start_actor_with_timeout/4` for an explicit timeout.
pub fn start_actor(
typed_name: TypedName(msg),
initial_state: state,
handler: fn(msg, state) -> HandlerStep(state),
) -> Result(GlobalSubject(msg), ActorStartError) {
dist_actor.start_default(typed_name, initial_state, handler)
}
/// Start a named actor with an explicit init timeout.
pub fn start_actor_with_timeout(
typed_name: TypedName(msg),
initial_state: state,
handler: fn(msg, state) -> HandlerStep(state),
init_timeout_ms: Int,
) -> Result(GlobalSubject(msg), ActorStartError) {
dist_actor.start(typed_name, initial_state, handler, init_timeout_ms)
}
/// Start a named actor with a decode-error callback. Useful for
/// logging or metering malformed messages across nodes (e.g. during
/// rolling deploys with mismatched codec versions). Uses
/// `config.get().default_init_timeout_ms`. If you need a custom
/// init timeout, drop down to `actor.start_observed` directly.
pub fn start_actor_observed(
typed_name: TypedName(msg),
initial_state: state,
handler: fn(msg, state) -> HandlerStep(state),
on_decode_error: fn(codec.DecodeError) -> Nil,
) -> Result(GlobalSubject(msg), ActorStartError) {
dist_actor.start_observed(
typed_name,
initial_state,
handler,
config.get().default_init_timeout_ms,
on_decode_error,
)
}
/// Start an actor and register it globally in one step. Uses
/// `config.get().default_init_timeout_ms`. Use
/// `start_registered_with_timeout/4` for an explicit timeout, or
/// `actor.start_registered_observed` for the decode-error hook variant.
pub fn start_registered(
typed_name: TypedName(msg),
initial_state: state,
handler: fn(msg, state) -> HandlerStep(state),
) -> Result(GlobalSubject(msg), StartRegisteredError) {
dist_actor.start_registered_default(typed_name, initial_state, handler)
}
/// Like `start_registered`, with an explicit init timeout.
pub fn start_registered_with_timeout(
typed_name: TypedName(msg),
initial_state: state,
handler: fn(msg, state) -> HandlerStep(state),
init_timeout_ms: Int,
) -> Result(GlobalSubject(msg), StartRegisteredError) {
dist_actor.start_registered(
typed_name,
initial_state,
handler,
init_timeout_ms,
)
}
/// Start a supervised actor using `config.get().default_init_timeout_ms`.
pub fn start_supervised(
typed_name: TypedName(msg),
initial_state: state,
handler: fn(msg, state) -> HandlerStep(state),
) -> Result(process.Pid, ActorStartError) {
dist_actor.start_supervised_default(typed_name, initial_state, handler)
}
/// Like `start_supervised`, with an explicit init timeout.
pub fn start_supervised_with_timeout(
typed_name: TypedName(msg),
initial_state: state,
handler: fn(msg, state) -> HandlerStep(state),
init_timeout_ms: Int,
) -> Result(process.Pid, ActorStartError) {
dist_actor.start_supervised(
typed_name,
initial_state,
handler,
init_timeout_ms,
)
}
/// Start N supervised actors using `config.get().default_init_timeout_ms`.
pub fn pool(
typed_name: TypedName(msg),
size: Int,
initial_state: state,
handler: fn(msg, state) -> HandlerStep(state),
) -> Result(process.Pid, ActorStartError) {
dist_actor.pool_default(typed_name, size, initial_state, handler)
}
/// Like `pool`, with an explicit init timeout.
pub fn pool_with_timeout(
typed_name: TypedName(msg),
size: Int,
initial_state: state,
handler: fn(msg, state) -> HandlerStep(state),
init_timeout_ms: Int,
) -> Result(process.Pid, ActorStartError) {
dist_actor.pool(typed_name, size, initial_state, handler, init_timeout_ms)
}
/// OTP child spec using `config.get().default_init_timeout_ms`.
pub fn child_spec(
typed_name: TypedName(msg),
initial_state: state,
handler: fn(msg, state) -> HandlerStep(state),
) -> supervision.ChildSpecification(GlobalSubject(msg)) {
dist_actor.child_spec_default(typed_name, initial_state, handler)
}
/// Like `child_spec`, with an explicit init timeout.
pub fn child_spec_with_timeout(
typed_name: TypedName(msg),
initial_state: state,
handler: fn(msg, state) -> HandlerStep(state),
init_timeout_ms: Int,
) -> supervision.ChildSpecification(GlobalSubject(msg)) {
dist_actor.child_spec(typed_name, initial_state, handler, init_timeout_ms)
}
// -- Messaging ---------------------------------------------------------------
pub type SendError =
global.SendError
pub type CallError =
global.CallError
pub type ReceiveError =
receiver.ReceiveError
pub type EncodeError =
codec.EncodeError
pub type DecodeError =
codec.DecodeError
pub fn send(
subject: GlobalSubject(msg),
message: msg,
) -> Result(Nil, SendError) {
global.send(subject, message)
}
/// Send a response through a reply subject. Used by handlers to answer a `call`.
pub fn reply(
reply_to: process.Subject(BitArray),
response: resp,
encoder: codec.Encoder(resp),
) -> Result(Nil, SendError) {
global.reply(reply_to, response, encoder)
}
/// Receive a typed message using `config.get().default_call_timeout_ms`.
/// Use `receive_with_timeout/2` for an explicit timeout.
pub fn receive(subject: GlobalSubject(msg)) -> Result(msg, DecodeError) {
global.receive_default(subject)
}
/// Like `receive`, with an explicit timeout.
pub fn receive_with_timeout(
subject: GlobalSubject(msg),
timeout_ms: Int,
) -> Result(msg, DecodeError) {
global.receive(subject, timeout_ms)
}
/// Synchronous request/response with monitor-based `TargetDown` detection.
/// Uses `config.get().default_call_timeout_ms`. Use `call_with_timeout/4`
/// for an explicit timeout.
///
/// ## Late-reply caveat: choose `call_isolated` for long-running callers
///
/// `call` is the cheap default. It is **safe** for short-lived callers
/// (CLI tools, request handlers, scripts) whose process exits shortly
/// after the call returns: orphan late-replies die with the process.
///
/// It is **not** the right choice for long-lived processes (OTP
/// actors, supervisors, manager loops) that issue many `call`s under
/// sustained timeouts. A reply that arrives *after* `call` returns
/// `Error(Timeout)` cannot be evicted from the caller's mailbox by
/// the BEAM (no `erlang:alias/0`-aware Subject layout in the current
/// `gleam_erlang`); selective receive scans every orphan on every
/// subsequent `process.receive`, and tens of thousands of orphans
/// quietly degrade the caller's throughput.
///
/// For that shape, prefer `call_isolated/3`: it runs each call inside
/// a short-lived unlinked proxy process whose mailbox is reaped on
/// exit. See `global.call/4` and `docs/safety_and_limits.md` for the
/// full design rationale.
pub fn call(
target: GlobalSubject(req),
make_request: fn(process.Subject(BitArray)) -> req,
response_decoder: codec.Decoder(resp),
) -> Result(resp, CallError) {
global.call_default(target, make_request, response_decoder)
}
/// Like `call`, with an explicit timeout. Inherits the same
/// late-reply caveat. See `call/3`.
pub fn call_with_timeout(
target: GlobalSubject(req),
make_request: fn(process.Subject(BitArray)) -> req,
response_decoder: codec.Decoder(resp),
timeout_ms: Int,
) -> Result(resp, CallError) {
global.call(target, make_request, response_decoder, timeout_ms)
}
/// Mailbox-safe variant of `call` using
/// `config.get().default_call_timeout_ms`. Each invocation runs inside
/// a short-lived unlinked proxy process so orphan late-replies die
/// with the proxy instead of polluting the caller's mailbox.
/// Recommended for long-running callers issuing many RPCs under
/// sustained timeouts. See `global.call_isolated` for the full design
/// rationale.
pub fn call_isolated(
target: GlobalSubject(req),
make_request: fn(process.Subject(BitArray)) -> req,
response_decoder: codec.Decoder(resp),
) -> Result(resp, CallError) {
global.call_isolated_default(target, make_request, response_decoder)
}
/// Like `call_isolated`, with an explicit timeout.
pub fn call_isolated_with_timeout(
target: GlobalSubject(req),
make_request: fn(process.Subject(BitArray)) -> req,
response_decoder: codec.Decoder(resp),
timeout_ms: Int,
) -> Result(resp, CallError) {
global.call_isolated(target, make_request, response_decoder, timeout_ms)
}
pub fn send_error_to_string(err: SendError) -> String {
global.send_error_to_string(err)
}
pub fn call_error_to_string(err: CallError) -> String {
global.call_error_to_string(err)
}
pub fn receive_error_to_string(err: ReceiveError) -> String {
receiver.receive_error_to_string(err)
}
pub fn encode_error_to_string(err: EncodeError) -> String {
codec.encode_error_to_string(err)
}
pub fn decode_error_to_string(err: DecodeError) -> String {
codec.decode_error_to_string(err)
}