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

cluster_name()

@type cluster_name() :: atom()

server_id()

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

Functions

command(server_id, command, timeout \\ 5000)

@spec command(server_id(), term(), timeout()) :: {:ok, term()} | {:error, term()}

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.

delete(server_id)

@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.

leader?(server_id, timeout \\ 5000)

@spec leader?(server_id(), timeout()) :: boolean()

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.

local_member?(server_id)

@spec local_member?(server_id()) :: boolean()

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.

local_query(server_id, query_fun)

@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.

member_node(nodes)

@spec member_node([node()]) :: node()

A node that actually hosts a replica: the local node when it is a member, else the first.

project(server_id, mfa, timeout \\ 5000)

@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.

query(server_id, timeout \\ 5000)

@spec query(server_id(), timeout()) :: {:ok, term()} | {:error, term()}

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).

ready?(server_id, timeout \\ 5000)

@spec ready?(server_id(), timeout()) :: boolean()

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.

reconcile(machine, cluster_name, nodes)

@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.

resume(server_id)

@spec resume(server_id()) :: :ok | :not_hosted | {:error, term()}

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).

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

@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.