Malachi.Cluster.RingServer (malachi v0.22.7)

View Source

The cluster's durable ring: a thin named facade over Malachi.Cluster.RaCluster running Malachi.Cluster.RingMachine. Start the dedicated Raft cluster, seed it at first boot, publish ring changes under the lease fence, and read the topology of record.

Why this cluster has no bootstrap circularity

The ring says where the metadata vnodes live, so storing it inside those vnodes would be circular. It is not: this is a separate Raft group whose membership comes from the static MALACHI_LOG_NODES, the same list the lease cluster forms over. The layering is

  • MALACHI_LOG_NODES (static environment) fixes this cluster's membership, depending on nothing;
  • this cluster answers with the topology;
  • the metadata vnodes are started from that topology.

The only genuine bootstrap is the very first boot, when nothing is recorded. init/2 handles it with an idempotent compare-and-set, so every node may seed concurrently and exactly one wins, the same auto-fenced move RaCluster.start/3 already makes when forming a cluster. The seeds are identical anyway: placement is deterministic rendezvous hashing over the same static node list.

Cost

Read once per boot, and twice per orphan-sweep pass on every node of a sharded cluster that has a candidate to decide (Malachi.Application.durable_orphan_authority/1, every five minutes by default); written twice per vnode split (the intent, then the completed ring). Routing on the hot path never comes here: it reads the gossiped topology held in memory. So this store adds a boot-time dependency and a slow periodic reader, not a request-path one.

Summary

Functions

Publishes a ring change under the writer's lease fence, extending the topology it read at expected_version. Returns :ok, {:error, {:conflict, ring}} when another writer moved the ring first (or this writer's fence is stale), or {:error, reason} when the cluster is unreachable.

Stops and deletes the ring's Raft cluster, removing its on-disk state.

The full replicated store (topology plus the highest fence seen), for diagnostics.

Seeds a cluster that has never had a ring. Returns :ok when this call planted the topology, or {:error, {:exists, current}} when one was already recorded, which at first boot simply means another node won the race and current is what to adopt. {:error, reason} when the cluster is unreachable.

Keeps this node joined to the ring cluster, so a staggered boot converges to a fully-replicated ring. Idempotent and best-effort; meant to be called periodically until the node has joined.

Starts the ring's Raft cluster named cluster_name across nodes, returning a server_id addressing a real member. Resume-first, so a node that already hosted this cluster comes back with its history rather than as an empty member.

The topology of record, as the three-valued read boot depends on: {:ok, topology} when a ring is recorded, {:ok, :none} when the cluster affirms it has never had one, and {:error, reason} when it cannot answer (no quorum yet, typically the first node of a full-cluster restart).

Types

cluster_name()

@type cluster_name() :: atom()

server_id()

@type server_id() :: {cluster_name(), node()}

Functions

advance(server_id, expected_version, fence, topology)

@spec advance(
  server_id(),
  non_neg_integer(),
  non_neg_integer(),
  Malachi.Cluster.RingTopology.t()
) ::
  :ok | {:error, term()}

Publishes a ring change under the writer's lease fence, extending the topology it read at expected_version. Returns :ok, {:error, {:conflict, ring}} when another writer moved the ring first (or this writer's fence is stale), or {:error, reason} when the cluster is unreachable.

The conflict is the guard a lease cannot give on its own: a holder that lost the lease without noticing still believes it leads, and is stopped here by the log rather than by a timeout.

delete(target)

@spec delete(server_id() | cluster_name()) :: :ok | {:error, term()}

Stops and deletes the ring's Raft cluster, removing its on-disk state.

get(server_id)

@spec get(server_id()) :: {:ok, Malachi.Cluster.Ring.t()} | {:error, term()}

The full replicated store (topology plus the highest fence seen), for diagnostics.

init(server_id, topology)

@spec init(server_id(), Malachi.Cluster.RingTopology.t()) :: :ok | {:error, term()}

Seeds a cluster that has never had a ring. Returns :ok when this call planted the topology, or {:error, {:exists, current}} when one was already recorded, which at first boot simply means another node won the race and current is what to adopt. {:error, reason} when the cluster is unreachable.

reconcile(cluster_name, nodes)

@spec reconcile(cluster_name(), [node()]) :: :ok

Keeps this node joined to the ring cluster, so a staggered boot converges to a fully-replicated ring. Idempotent and best-effort; meant to be called periodically until the node has joined.

start(cluster_name, nodes \\ [node()])

@spec start(cluster_name(), [node()]) :: {:ok, server_id()} | {:error, term()}

Starts the ring's Raft cluster named cluster_name across nodes, returning a server_id addressing a real member. Resume-first, so a node that already hosted this cluster comes back with its history rather than as an empty member.

topology(server_id, timeout \\ 5000)

@spec topology(server_id(), timeout()) ::
  {:ok, Malachi.Cluster.RingTopology.t()} | {:ok, :none} | {:error, term()}

The topology of record, as the three-valued read boot depends on: {:ok, topology} when a ring is recorded, {:ok, :none} when the cluster affirms it has never had one, and {:error, reason} when it cannot answer (no quorum yet, typically the first node of a full-cluster restart).

Boot must keep those three apart: only the middle one licenses seeding from MALACHI_LOG_VNODES. Treating the third as the second is exactly the bug this store exists to fix.

timeout bounds the read, ra's own five seconds by default; a caller on a periodic path that must skip rather than wait passes a short one.