Malachi. Cluster. Capabilities
(malachi v0.22.7)
View Source
What this binary can do, said out loud to the rest of the cluster, and the pure check that asks whether every node can do a given thing.
Why a node has to say what it supports
Malachi.Cluster.MachineVersion already stops a control-plane command from being applied by some
members and skipped by others: ra moves a group to a new machine version only once every member
supports it. That covers the Raft log and nothing else. A storage frame, a replication message or a
compression codec never passes through a Raft log, so nothing there tells a newer node to hold off
while its peers are still old. This module is that missing half: a node advertises a named
capability set, and a feature asks whether the whole cluster advertises the one it needs before it
is switched on (Malachi.Cluster.ClusterFlags).
Named capabilities rather than one rising integer, because a release order is not a dependency order: a backport reorders integers, and one feature cannot be held back on its own under them.
How it travels
Inside the SWIM membership attributes, under the reserved atom key :capabilities, as a sorted list
of atoms. That is deliberate:
- The gossip payload already carries
attributesas the fourth element of every update (Malachi.Cluster.Membership), so no message shape changes. A member on the old binary keeps the key it does not understand and gossips it onward untouched. Widening the update tuple instead would raise insideMembership.merge/2on that member, which the catch-all ofMalachi.UnexpectedMessagedoes not cover: it only guards the outer shape of a message. - The key cannot collide with an operator's own attributes:
Malachi.Application.parse_attributes/1produces only string keys. - Atoms rather than an integer bitmask, because a bitmask index is a second version contract alongside the machine version, and a backport that adds a capability out of order breaks reading an old advertisement in silence. Erlang distribution caches an atom per connection, so a repeated name costs a byte or two. If this list ever passes about sixteen entries, measure the payload rather than guess.
One name for both halves
A flag's name is the capability it requires. One registry, one name in the logs, one name on the command line. A flag that one day needs two capabilities turns the registry into a map in a change of its own; the flag's own name keeps working as the key.
Where the merge lives
Malachi.Cluster.Membership.set_attributes/2 replaces the whole attribute map, so any path that sets
attributes at runtime would erase the capabilities. attributes/2 is the single function that puts
them in, and both paths into a node's own attributes reach it:
- the seed, through
Malachi.Application.membership_attributes/0, when the server starts; - every runtime change, through
ensure/1, whichMalachi.Cluster.MembershipServer.set_attributes/2applies before it replaces the map.
What that guarantees is one thing and not another. A caller that says nothing about capabilities
cannot erase them, which is the whole failure this closes: an operator changing a rack on a running
node would otherwise empty the list on every peer, with nothing to correct it. A caller that does
state a list keeps it, so this is not a guarantee that a node advertises only what its build
implements. Nothing in production states one, because the list is a compile-time property of the
build, and the multinode tests do, because one VM cannot run two builds and they have to play a
cluster caught mid-upgrade. Before removing a check elsewhere on the strength of this, read ensure/1.
Summary
Types
The membership attributes a member gossips (Malachi.Cluster.Membership).
A capability, which is also the name of the flag that requires it.
One read of the membership view: what a node's status and attributes are, or {nil, %{}}.
Functions
The capabilities this node advertises to its peers, sorted so the advertisement is stable.
This node's membership attributes: the operator's own attributes with this build's capability list
merged in under key/0.
attributes with this build's capability list added, when the caller did not state one.
The reserved attribute key this node's capability list is gossiped under.
Every capability this build knows by name, sorted.
The capabilities attributes advertises, or [] when it advertises none.
Resolves an operator's input to one of known, or {:error, :unknown_flag}.
Whether every node in nodes advertises capability, answering :ok or naming the ones that do
not.
Types
@type attributes() :: Malachi.Cluster.Membership.attributes()
The membership attributes a member gossips (Malachi.Cluster.Membership).
@type capability() :: atom()
A capability, which is also the name of the flag that requires it.
@type reads() :: (node() -> {Malachi.Cluster.Membership.status() | nil, attributes()})
One read of the membership view: what a node's status and attributes are, or {nil, %{}}.
Functions
@spec advertised() :: [capability()]
The capabilities this node advertises to its peers, sorted so the advertisement is stable.
@spec attributes(attributes(), [capability()]) :: attributes()
This node's membership attributes: the operator's own attributes with this build's capability list
merged in under key/0.
The single place the merge happens, and it overwrites: whatever operator_attributes said about
capabilities is replaced by capabilities. An operator key can never shadow the list either, because
operator keys are strings.
A caller setting attributes on a running node does not have to come through here, but not because this
is applied for it: Malachi.Cluster.MembershipServer.set_attributes/2 goes through ensure/1, which
fills the list in only when the caller left it out. See that function for what the difference buys.
@spec ensure(attributes()) :: attributes()
attributes with this build's capability list added, when the caller did not state one.
This is what the runtime path goes through: Malachi.Cluster.MembershipServer.set_attributes/2
replaces the whole attribute map, so an operator changing a rack on a running node would otherwise
erase what it advertises, and its peers would keep the emptied list. A live node is never suspected,
so nothing raises its incarnation to correct that.
A caller that does state a list keeps it. In production nothing does, because the list is a compile-time property of the build; in the tests it is the seam that stands one server up as though it ran another build, which is the only way a single VM can play a cluster mid-upgrade.
@spec key() :: atom()
The reserved attribute key this node's capability list is gossiped under.
@spec known() :: [capability()]
Every capability this build knows by name, sorted.
This is the registry the operator's input is resolved against and the set a flag name must belong
to. It is the same list as advertised/0 today; they are separate functions because a future build
may know a name it cannot yet honour.
@spec of(attributes()) :: [capability()]
The capabilities attributes advertises, or [] when it advertises none.
A member on a build that predates this module has no such key, which reads as advertising nothing. That is the safe answer: it is exactly the member a flag must not be switched on over.
@spec resolve(String.t(), [capability()]) :: {:ok, capability()} | {:error, :unknown_flag}
Resolves an operator's input to one of known, or {:error, :unknown_flag}.
Never String.to_atom/1: the input is a command-line string, and turning arbitrary text into atoms
grows a table that is never collected. Matching against known/0 also means an unknown name is
refused before anything reaches the Raft log.
@spec supported_by_all([node()], capability(), reads()) :: :ok | {:error, {:unsupported, [node()]}}
Whether every node in nodes advertises capability, answering :ok or naming the ones that do
not.
reads is the membership view, injected as node -> {status, attributes} so this stays pure over
its inputs. A node counts as advertising only when it is :alive and its attributes list the
capability. Anything else is refused and named: :suspect, :dead, unknown to the view at all
(which answers {nil, %{}}), or simply running a build that does not have the capability.
nodes is the statically configured node set, not the membership's alive set and not a Raft group's
members. The alive set silently omits a node that has just joined on an old build, and a Raft group
omits any node whose build does not have that group's machine module at all, which during a rolling
upgrade is every node not yet upgraded. The configured set is the only population that contains a
node the cluster has not heard from, and refusing over it is the safe direction: a stale view can
only withhold a capability, never invent one, because attributes travel with their own member's
incarnation.
Monotone by construction: adding a node can only move the answer from :ok to a refusal, never the
other way.