Malachi.Cluster.Ring (malachi v0.22.7)

View Source

The pure state of the cluster's durable routing topology: the Malachi.Cluster.RingTopology of record, plus the highest lease fence that has written it. It is the deterministic core replicated by Malachi.Cluster.RingMachine over a dedicated ra cluster, exactly as Malachi.Cluster.Lease sits behind LeaseMachine and Malachi.Metadata behind MetadataMachine.

Gossip disseminates the ring quickly; this is what makes it survive a full-cluster stop. Without it the ring reseeded from MALACHI_LOG_VNODES on restart, whose even geometry does not match a ring grown by splitting, so the cluster came back routing to vnodes that no longer owned the metadata.

Why the whole topology, not just the ring

The stored value is the entire RingTopology: ring, placements and the pending-split intent. A split records its intent before migrating so a coordinator taking over the lease can carry it to completion; keeping that intent here means a restart in the middle of a split does not lose it either.

Why the commands are fenced

Only the lease holder writes the ring, so in the happy path there is one writer. The case a lease does not cover is a holder that has lost the lease and not yet noticed. So advance is a compare-and-set on the version the writer believed it was extending, and carries the writer's lease fence (Malachi.Cluster.Lease's monotonic token, which advances whenever the holder changes). A stale writer fails both tests and is refused by the log rather than by a timeout.

A renewing holder keeps its fence, so consecutive splits by the same leader carry the same token: the fence must be non-decreasing, not strictly increasing. The version, in contrast, advances on every publish, so it is strictly increasing.

Summary

Types

{:init, topology} seeds a cluster that has never had a ring; it is idempotent and auto-fenced, so every node may send it at first boot and exactly one wins. {:advance, expected_version, fence, topology} publishes a ring change, accepted only when the stored version is still expected_version and fence is at least the highest seen.

t()

Functions

Applies a command, returning {new_state, reply}. Deterministic: no clock, no randomness, no node identity, so every replica reaches the same state from the same log.

Every command shape, mapped to the machine version that introduced it (see Malachi.Cluster.MachineVersion).

A store with no ring recorded yet (the first-boot state).

The stored topology as a three-valued answer: {:ok, topology} when a ring is recorded, :none when this cluster affirms it has never had one. The distinction is the whole reason the ring lives in ra: an unreachable store answers neither, so boot can tell "genuinely fresh cluster" from "I cannot see the record", and only ever seeds from the environment for the former.

The version of the stored topology, or nil when none is recorded.

Types

command()

@type command() ::
  {:init, Malachi.Cluster.RingTopology.t()}
  | {:advance, expected_version :: non_neg_integer(),
     fence :: non_neg_integer(), Malachi.Cluster.RingTopology.t()}

{:init, topology} seeds a cluster that has never had a ring; it is idempotent and auto-fenced, so every node may send it at first boot and exactly one wins. {:advance, expected_version, fence, topology} publishes a ring change, accepted only when the stored version is still expected_version and fence is at least the highest seen.

reply()

@type reply() ::
  :ok
  | {:error, {:exists, Malachi.Cluster.RingTopology.t()}}
  | {:error, {:conflict, t()}}

t()

@type t() :: %Malachi.Cluster.Ring{
  fence: non_neg_integer(),
  topology: Malachi.Cluster.RingTopology.t() | nil
}

Functions

apply(state, arg)

@spec apply(t(), command()) :: {t(), reply()}

Applies a command, returning {new_state, reply}. Deterministic: no clock, no randomness, no node identity, so every replica reaches the same state from the same log.

{:init, _} on a store that already holds a ring answers {:error, {:exists, current}} rather than overwriting: that is what makes the concurrent first-boot seed safe. {:advance, _, _, _} answers {:error, {:conflict, state}} when the compare-and-set fails, handing back the whole state so the caller can re-read without another round trip.

command_versions()

Every command shape, mapped to the machine version that introduced it (see Malachi.Cluster.MachineVersion).

new()

@spec new() :: t()

A store with no ring recorded yet (the first-boot state).

topology(ring)

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

The stored topology as a three-valued answer: {:ok, topology} when a ring is recorded, :none when this cluster affirms it has never had one. The distinction is the whole reason the ring lives in ra: an unreachable store answers neither, so boot can tell "genuinely fresh cluster" from "I cannot see the record", and only ever seeds from the environment for the former.

version(ring)

@spec version(t()) :: non_neg_integer() | nil

The version of the stored topology, or nil when none is recorded.