Skip to main content

Clustering

Converger scales horizontally: every node runs the same release, serves HTTP and WebSockets, and executes Oban jobs. Postgres is the shared state. On top of that the nodes form an Erlang cluster so that in-memory, real-time state reaches every node. This page explains how nodes find each other, what the cluster is used for, and how it is tested. The decision is recorded in ADR-0037; Kubernetes specifics are on the Kubernetes page.

What needs the cluster​

FeatureShared throughWithout a cluster (N independent nodes)
WebSocket broadcasts (new_activity and other conversation events)Phoenix.PubSub (PG2 adapter over distribution)A client only sees activities created on the node it is connected to
Presence (ConvergerWeb.SocketPresence)Phoenix Presence CRDT over PubSubPresence lists are per node
Admin LiveView updates (channel health, dashboard)PubSubOnly events from the same node appear live
Rate limits (RATE_LIMIT_BACKEND=cluster)Counter deltas over PubSub (Rate limiting)Every node counts alone: up to N times the limit
Tenant rate-limit override cache invalidationPubSubStale overrides for up to 30 s on other nodes
Oban jobs (deliveries, cron)Postgres (SKIP LOCKED, leader election in the oban_peers table)Works without a cluster: each job runs exactly once
Broadway pipeline (Converger.Pipeline.Broadway, not the default)Nothing: each node processes what it pushed itselfSame; retries go through Oban
Broker channel consumers (kafka, rabbitmq, nats, redis_streams, mqtt) and Discord Gateway connections:global registration (one consumer per channel), channel-change notifications over PubSubEvery node runs a consumer for every broker channel: still no duplicate activities (idempotency keys), but Kafka splits the partitions among them and RabbitMQ, NATS and Redis the messages, so ordering is per node; two MQTT subscribers share a client id and the broker disconnects one of them in turn; two Discord Gateway sessions both receive every message (duplicates are recognised by message id)

Oban needs no distribution, so deliveries are exactly-once even across a netsplit. Everything in the PubSub column degrades to per-node behaviour while nodes are disconnected and recovers when they reconnect.

Broker channel consumers are spread over the nodes (rendezvous hashing on the channel id) and move to another node when theirs leaves the cluster (within BROKER_RECONCILE_INTERVAL_MS, 30 s). During a netsplit each side may start a consumer for the same channel; when the partition heals :global stops one of them. BROKER_CONSUMERS=false keeps consumers off a node. See broker channels.

Choosing a strategy​

Node discovery uses libcluster. CLUSTER_STRATEGY selects the topology (Converger.Cluster builds it at boot; an unknown strategy or a missing required option raises, so a misconfigured release fails instead of running unclustered):

CLUSTER_STRATEGYlibcluster strategyPlatformRequired settings
none (default)nonesingle node, development, testnone
kubernetes_dnsCluster.Strategy.Kubernetes.DNSKubernetes with a headless ServiceCLUSTER_SERVICE
dnsCluster.Strategy.DNSPollFly.io (<app>.internal), ECS/Consul service discovery, any DNS name listing the node IPsCLUSTER_DNS_QUERY
gossipCluster.Strategy.Gossipdocker compose, VMs on one L2 networkCLUSTER_GOSSIP_SECRET (recommended)
epmdCluster.Strategy.Epmd with CLUSTER_HOSTS, else Cluster.Strategy.LocalEpmddevelopment, fixed hostsCLUSTER_HOSTS (optional)

kubernetes_dns needs no Kubernetes API access (no RBAC): it only resolves the headless Service. Every variable is listed in the Configuration reference.

DNS_CLUSTER_QUERY, the setting of the former dns_cluster dependency, still works: when CLUSTER_STRATEGY is unset it implies CLUSTER_STRATEGY=dns with that query.

Erlang distribution in releases​

All strategies connect nodes named <basename>@<ip> that share a cookie. rel/env.sh.eex, sourced by bin/converger before every command, sets this up when CLUSTER_STRATEGY is not none (any value already in the environment wins):

VariableSet toNotes
RELEASE_COOKIEyou must set itThe same random secret on every node, from your secret store (openssl rand -base64 48). bin/converger start refuses to boot while clustering is enabled and it is unset: the cookie generated at build time is baked into the image and identical for everyone who has the image.
RELEASE_DISTRIBUTIONnameLong names, required for name@ip.
RELEASE_NODE${CLUSTER_NODE_BASENAME:-converger}@<ip><ip> is POD_IP (Kubernetes downward API), else FLY_PRIVATE_IP, else the first address of hostname -i. For an IPv6 address, -proto_dist inet6_tcp is added to ERL_AFLAGS.
CLUSTER_DIST_PORToptionalA fixed distribution port for the server (start/daemon only; rpc and remote keep a random one). The Helm chart sets 9100.

The distribution listens on EPMD (port 4369) and a dynamic port (or CLUSTER_DIST_PORT). Nodes must reach each other on both; on Kubernetes and docker compose pod-to-pod / container-to-container traffic is open by default. Anyone who can reach those ports and knows the cookie can run code on the node: keep them on the private network and treat the cookie like SECRET_KEY_BASE. bin/migrate (eval) does not start the distribution and needs no cookie.

The rate-limit backend follows clustering: with a strategy other than none, RATE_LIMIT_BACKEND defaults to cluster.

Running a cluster​

Kubernetes: kubectl apply -k deploy/k8s or the Helm chart in deploy/helm/converger, see Kubernetes.

docker compose: docker-compose.cluster.yml adds a second app service and turns on the gossip strategy for both:

# .env additionally needs RELEASE_COOKIE and CLUSTER_GOSSIP_SECRET (see .env.example)
docker compose -f docker-compose.yml -f docker-compose.cluster.yml up -d --build
# node A on http://localhost:4000, node B on http://localhost:4001
docker compose exec app /app/bin/converger rpc 'IO.inspect(Node.list())'

Fly.io: deploy/fly/fly.toml uses CLUSTER_STRATEGY=dns with CLUSTER_DNS_QUERY=<app>.internal; the node address comes from FLY_PRIVATE_IP (IPv6), migrations run as the release_command, and the HTTP service check uses /health/ready. Set RELEASE_COOKIE with fly secrets set.

Local development, two nodes on one machine:

CLUSTER_STRATEGY=epmd iex --sname a -S mix phx.server
PORT=4001 CLUSTER_STRATEGY=epmd iex --sname b -S mix phx.server

With no CLUSTER_HOSTS, the epmd strategy connects every node registered in the local EPMD. Node.list() in either shell shows the other node.

Verifying a cluster​

On a running release:

bin/converger rpc 'IO.inspect(Node.list())' # connected nodes
bin/converger rpc 'IO.inspect(Converger.RateLimit.backend())' # :cluster
curl -s http://<pod-ip>:4000/health/ready # {"status":"ready",...}

libcluster logs [libcluster:converger] connected to :"[email protected]" when a node joins and unable to connect to ... when it cannot reach one (wrong cookie, blocked port, wrong basename).

The two-node test suite​

test/cluster/multi_node_test.exs boots two Converger nodes with OTP's :peer and checks the guarantees above end to end:

  • an activity created over node A's REST API arrives at a WebSocket client connected to node B (a real WebSocket over TCP, test/support/cluster/ws_client.ex);
  • the HTTP rate limit is shared: after node A has used up a client's budget, node B answers 429;
  • with both nodes running the Oban queues, 20 activities created on both nodes produce exactly 20 webhook deliveries (counted by a local HTTP sink) and every delivery job completes on its first attempt;
  • both nodes report ready on /health/ready.

The peers form a real Erlang cluster (long names on 127.0.0.1, connected by libcluster's Epmd strategy) while the test node controls them over stdio, so the test node itself does not need to be distributed. Because SQL sandbox transactions cannot span nodes, the peers use a separate database, <test database>_cluster, with a normal connection pool: the suite creates and migrates it with Converger.Release and truncates it before the run.

The suite is tagged :cluster and excluded by default (it starts two extra VMs and takes about 30 s):

mix test --only cluster

CI runs it in the "Two-node cluster suite" job (.github/workflows/ci.yml).