Malachi.Cluster.ClusterFlagsCache (malachi v0.22.7)

View Source

This node's local view of the cluster's feature flags: the read every feature on a hot path uses, the step that adopts a newly enabled flag, and the gate that stops a node which cannot honour one.

Why a cache at all

A produce or a replication append cannot ask Raft whether a flag is on. So the flags live in :persistent_term, which is read without copying and is the primitive this repo already uses for cluster state that is read constantly and written almost never (Malachi.Consumer.CoordinatorRouter).

Why a lagging cache is safe

Because a flag only ever goes from off to on here, and it only goes on once every node advertises the capability it names. The first half of that is enforced by adopt/2, which merges rather than replaces: the replicated set only grows, but a read of a lagging local replica can still answer with fewer flags than a previous read did. A node that has not yet noticed a flip keeps to the old behaviour, which every node in the cluster still understands, and a node that has noticed it produces the new behaviour, which every node can already handle. Neither direction can surprise a peer. That is the whole argument for refreshing on a 30 second tick instead of reading Raft per batch, and it is why Malachi.Cluster.ClusterFlags has no command to switch a flag off.

Why the write is conditional

:persistent_term.put/2 triggers a global garbage-collection scan of every process in the VM. Writing on every tick would make each node pay that twice a minute, forever, to store a value that changes once per release. refresh/1 writes only when the value actually changed.

Order of adoption

A flag's local side effect runs before the flag is published to the cache. #207 raises the data-directory format marker when its flag flips, and the marker has to be up before the first byte of the new format is written; publishing first would leave a window where this node believes the feature is on and its own directory still says otherwise. If the side effect raises, nothing is published and the next tick tries again.

The gate

A flag this node's build does not advertise cannot be honoured, and the flag will never be switched off. Serving anyway means writing data the cluster expects to be in another shape. So the node refuses to run, through Malachi.StartupRefusal, with the same exit status 78 an unreadable data directory uses. In practice this can only be reached by a node restarting on an older build after the flip, since the flip itself required every node to advertise the capability.

A store that cannot be read yet (not formed, no quorum, this node still joining) never refuses and never publishes: there is no answer, and treating a failure to ask as an answer of no is the mistake the durable ring boot exists to avoid. What a node does in the meantime is carry on with the old behaviour, which every node in the cluster still understands, and try again on the next tick. It does not refuse to start, and it does not hold itself out of rotation: either would deadlock the one case this whole release exists for, because the flag store cannot reach a quorum until enough nodes run a build that has its machine module, so the first node of a rolling upgrade would be waiting for a cluster that is waiting for it. read?/0 says which state a node is in, for the feature that will need to act on it.

Summary

Functions

Applies flags this node has already read: refuse if this build cannot honour one, adopt what is new, publish.

Every flag this node has seen switched on, sorted. [] before the store has been read.

Whether flag is switched on, as far as this node has seen. The hot-path read: a :persistent_term lookup and a list membership test over a list that is almost always empty.

Whether this node has read the flag store at least once, as opposed to having read it and found nothing: enabled/0 answers [] for both, and the difference matters to anything that would hold a node back until it knows.

One pass over the flag store: read, refuse if this build cannot honour what is on, adopt what is new, and publish.

Functions

adopt(flags, opts \\ [])

@spec adopt(
  Malachi.Cluster.ClusterFlags.t(),
  keyword()
) :: :ok

Applies flags this node has already read: refuse if this build cannot honour one, adopt what is new, publish.

Split from refresh/1 so a caller that has already read the store applies what it read without a second round trip through Raft.

enabled()

@spec enabled() :: [Malachi.Cluster.ClusterFlags.flag()]

Every flag this node has seen switched on, sorted. [] before the store has been read.

enabled?(flag)

Whether flag is switched on, as far as this node has seen. The hot-path read: a :persistent_term lookup and a list membership test over a list that is almost always empty.

read?()

@spec read?() :: boolean()

Whether this node has read the flag store at least once, as opposed to having read it and found nothing: enabled/0 answers [] for both, and the difference matters to anything that would hold a node back until it knows.

Nothing gates on this yet, deliberately. Readiness does not, because the flag store cannot reach a quorum until enough nodes run a build that has its machine module, and a StatefulSet rolling update will not start the second pod until the first is Ready: gating readiness on the flags would stall the rollout of the very release that introduces them. Nor does it need to yet, since this release's capability registry is empty, so no flag can be enabled at all.

It exists for the first feature that ships a capability (#202), which will run with the store already formed cluster-wide and no bootstrap left to deadlock. That is the same shape as Malachi.Storage.FormatMarker.raise_to/3: the hook lands with the mechanism, wired by the feature that needs it.

refresh(opts)

@spec refresh(keyword()) :: :ok

One pass over the flag store: read, refuse if this build cannot honour what is on, adopt what is new, and publish.

Called from the flag store's reconciler tick, so it runs on every node in every mode, at boot and for as long as the node lives.

Options, all seams so the pass is testable without a cluster or a live VM to halt:

  • :read (required) - (mode -> {:ok, ClusterFlags.t()} | {:error, reason}), normally Malachi.Cluster.ClusterFlagsServer.read/2 bound to this node's member;

  • :advertised - what this build can honour (default Capabilities.advertised/0);
  • :adopt - (flag -> any), the local side effect of a newly enabled flag (default none);
  • :halt_fun - how a refusal stops the node (default System.halt/1).