Malachi. Cluster. RaCluster
(malachi v0.22.7)
View Source
The shared ra lifecycle every one of this repo's Raft-backed stores needs: form or resume the
cluster, keep this node joined, submit commands through the log, query the replicated state, and
tear the cluster down.
Malachi.Cluster.MetadataServer, Malachi.Cluster.LeaseServer, Malachi.Auth.UserServer,
Malachi.Auth.LockoutServer and Malachi.Auth.AclServer each grew their own copy of this
skeleton, differing only in the machine module and the cluster name. That repetition is how
LeaseServer ended up as the one store that forms without resuming first, which is precisely the
amnesia path Malachi.Cluster.RaResume exists to close. Holding the lifecycle in one place means a
fix like that lands once.
Every function takes the machine module and cluster name explicitly, so a store is a thin named
facade over this module rather than a subclass of it. ra itself must already be running
(:ra.start_in/1); this module owns a cluster, never ra's lifecycle.
Summary
Functions
Submits command through the Raft log and returns {:ok, machine_reply}, or {:error, reason}
when the cluster is unreachable. The caller distinguishes a refusal (a machine reply that is itself
an error) from a failure to reach consensus.
Stops and deletes the cluster, removing its on-disk state. Prefer a server_id addressing a real
member, so a cluster placed on a subset of nodes is deleted through a node that hosts it: ra
finds the leader from there and propagates the deletion. A bare cluster_name means the local node.
Whether server_id is currently its cluster's leader. Pass the local server id
({cluster_name, node()}) to ask "does this node lead?". An unreachable or unformed cluster
answers false: never assume leadership.
Whether this node hosts a member of server_id's cluster, from ra's own local view.
Reads the local replica's state through query_fun (no consensus round-trip). Eventually
consistent: a just-written value propagates within replication lag. For hot paths that tolerate
that; use query/2 when the read must be linearizable.
A node that actually hosts a replica: the local node when it is a member, else the first.
A linearizable read that returns apply(m, f, a ++ [state]) computed inside the leader.
Reads the replicated machine state with a linearizable (consistent) query.
Whether the cluster is formed and reachable (a member answers :ra.members) within timeout.
Ensures this node participates in cluster_name (self-join), so a staggered boot converges to a
fully-replicated cluster. Idempotent and best-effort: it forms the cluster if it is not yet formed
(auto-fenced, every node may call) and starts the local server if it is not running. A no-op once
the local server is up, so a periodic caller stops paying after the node has joined.
Restarts server_id's member from its persisted log when this node has ever started it, and does
nothing else: :not_hosted when the name was never registered here. Never forms or joins, so it can
never register a fresh uid over a member that exists (the amnesia Malachi.Cluster.RaResume describes).
Starts cluster_name running machine across nodes, returning a server_id for a real
member: the local node when it is one (no network hop for reads), otherwise the first of nodes.
Types
@type cluster_name() :: atom()
@type server_id() :: {cluster_name(), node()}
Functions
Submits command through the Raft log and returns {:ok, machine_reply}, or {:error, reason}
when the cluster is unreachable. The caller distinguishes a refusal (a machine reply that is itself
an error) from a failure to reach consensus.
timeout bounds the wait for the commit (ra's own default is 5 s). A caller running inside a loop
that serves clients passes a shorter one. A timeout is ambiguous: the command may still commit.
@spec delete(server_id() | cluster_name()) :: :ok | {:error, term()}
Stops and deletes the cluster, removing its on-disk state. Prefer a server_id addressing a real
member, so a cluster placed on a subset of nodes is deleted through a node that hosts it: ra
finds the leader from there and propagates the deletion. A bare cluster_name means the local node.
Whether server_id is currently its cluster's leader. Pass the local server id
({cluster_name, node()}) to ask "does this node lead?". An unreachable or unformed cluster
answers false: never assume leadership.
:ra.members/2 is a leader call: a follower forwards it, and a cluster mid-election answers nothing
until timeout elapses. A caller that polls many clusters on one pass should pass a timeout well
below its own period, since the answer for a cluster without a leader is false either way and
waiting the default only delays the rest of the pass.
Whether this node hosts a member of server_id's cluster, from ra's own local view.
This is the live answer to "do I host this vnode", and it is deliberately not read from the
cluster's recorded placement: a placement is a routing decision, while membership changes under it
whenever a member is added or removed (Malachi.Cluster.Rebalance), and the two disagree from the
moment a rebalance runs.
:ra_leaderboard is an ETS table each node keeps for the clusters it hosts a member of; ra writes
it from the member process itself on every cluster or leadership change and clears it when the member
terminates, so it is populated exactly on the nodes that host a member. The read is local, never
reaches the network, and answers false rather than raising when the table or the entry is absent.
Because ra writes and clears the entry from the member process, this answer can lag a membership
change by the time that process takes to apply it: a member just removed can still read as local for a
moment. That direction is safe for every caller here, since a vnode that is gone answers "not the
leader" and has no machine-version counters, so a stale true produces no work and no false alarm.
@spec local_query(server_id(), (term() -> result)) :: {:ok, result} | {:error, term()} when result: term()
Reads the local replica's state through query_fun (no consensus round-trip). Eventually
consistent: a just-written value propagates within replication lag. For hot paths that tolerate
that; use query/2 when the read must be linearizable.
A node that actually hosts a replica: the local node when it is a member, else the first.
@spec project(server_id(), {module(), atom(), [term()]}, timeout()) :: {:ok, term()} | {:error, term()}
A linearizable read that returns apply(m, f, a ++ [state]) computed inside the leader.
For a caller that needs one part of a large state and should not copy the rest across the network.
The price is what makes this dangerous: ra 3.1 applies the {M, F, A} in the leader's server
process without a catch (ra_machine:apply_fun/3), so a function that raises, or that is missing
from the leader's code during a rolling upgrade or a rolled back build, takes that server down and
forces an election. Only pass a total function from the standard library, which every node has
whatever Malachi version it runs, such as {:maps, :with, [keys]}. Anything that has to run Malachi
code belongs in the caller, over what this returns.
Reads the replicated machine state with a linearizable (consistent) query.
The {Function, :identity, []} is ra's required shape: it only accepts an {M, F, A} and applies
it as apply(M, F, A ++ [State]), so asking for the state itself and projecting in the calling
process is both the simplest translation and the one that keeps a raising projection from taking
the replicated server down with it.
timeout bounds the wait, and defaults to ra's own 5s. That default is far too long for a caller
that runs inside a loop serving clients: such a caller passes a short one and reads the timeout as
"this cluster did not answer" (see Malachi.Cluster.ReplicatedDSRSM.snapshot/2).
Whether the cluster is formed and reachable (a member answers :ra.members) within timeout.
An unreachable member costs the whole timeout, so a caller on a latency-sensitive path passes a
short one rather than ra's 5s default.
@spec reconcile(module(), cluster_name(), [node()]) :: :ok
Ensures this node participates in cluster_name (self-join), so a staggered boot converges to a
fully-replicated cluster. Idempotent and best-effort: it forms the cluster if it is not yet formed
(auto-fenced, every node may call) and starts the local server if it is not running. A no-op once
the local server is up, so a periodic caller stops paying after the node has joined.
Restarts server_id's member from its persisted log when this node has ever started it, and does
nothing else: :not_hosted when the name was never registered here. Never forms or joins, so it can
never register a fresh uid over a member that exists (the amnesia Malachi.Cluster.RaResume describes).
@spec start(module(), cluster_name(), [node()]) :: {:ok, server_id()} | {:error, term()}
Starts cluster_name running machine across nodes, returning a server_id for a real
member: the local node when it is one (no network hop for reads), otherwise the first of nodes.
Resume-first (see Malachi.Cluster.RaResume): forming over a member this node has ever started
would register a fresh empty uid and resurrect an amnesiac member, orphaning the persisted log.