Malachi.Cluster.Failover (malachi v0.22.7)

View Source

Pure primary-failover policy for active segments: when an active segment's primary is no longer alive, or its live replicas can no longer reach the acknowledgement quorum, the segment is sealed and writing rolls to a fresh one, which is what NorthGuard does ("we just seal it, make a new one, move the producers over to that new segment").

Sealing rather than promoting is what makes this safe. A batch is acknowledged once a majority holds it durably, so a replica outside that majority can be behind. Promoting such a replica would let it append at offsets the dead primary had already assigned and acknowledged, leaving two replicas with different records under the same offset, which nothing downstream detects: the expected_first chain in replication rejects a gap rather than a conflict, and the integrity scrub verifies each copy against itself. Sealing removes that possibility by construction, because an offset the sealed segment already assigned is never handed out again. The freshly sealed segment is then picked up by Malachi.Cluster.SelfHealing, which already re-replicates sealed segments.

The fence is what makes the seal binding rather than advisory, and Malachi.Cluster.HealCoordinator applies it in two steps, in this order. It first MEASURES every live replica with Malachi.Cluster.ReplicationServer.durable_stats/4, which reports what a replica holds and leaves it writable. Only if those answers reach the seal quorum (seal_quorum/1) does it FENCE them with Malachi.Cluster.ReplicationServer.seal/4, and the fence answers are what plan/5 then seals on, so the point is recorded after every replica behind it has stopped accepting writes. A returning old primary is refused an append by each fenced replica it reaches, including by its own store after a restart, and so cannot close a quorum.

Measuring before fencing is not a nicety. A fence has no inverse: nothing unseals a replica's store. Fencing whatever answers, before knowing whether enough did, therefore closes replicas of a segment this pass may then decline to seal, and they keep refusing writes after their primary comes back, shrinking every later write quorum for nothing. Below the seal quorum the pass therefore leaves every replica untouched, and the range stays blocked until enough return.

Note what this still does not claim: a replica the pass never reached is not fenced.

The seal point, and when a range is left blocked

The seal is placed at the highest durable end any replica reports, and only when at least the seal quorum of the replica set answered the probe: n - q + 1 answers for n replicas and an acknowledgement quorum q (Malachi.Cluster.ReplicaTracker.quorum_size/1). Both halves are forced, not chosen.

The argument, per record rather than per replica, because the difference matters: take an acknowledged record at offset o. It lives on at least q replicas of the full set, the answering replicas are at least n - q + 1 of it, and two such subsets of n replicas always share one, since together they count more than n. So some answering replica holds o. A replica's log is contiguous (replication rejects a gap), so that replica's reported end is above o, and the highest end among the answers is at least that. The covering replica may be a different one for each record; no single answer need hold the whole segment, which is exactly why the seal takes the maximum instead of trusting one replica's view.

Any lower point, including the offset a majority of the ANSWERS agree on, can sit below an acknowledged record that only the dead primary and one survivor ever held, and sealing there would discard it: exactly what this policy exists to prevent. Below the seal quorum, no such intersection is guaranteed, the committed end is unknowable, and the segment is left alone with its range no longer accepting writes.

With three replicas the seal quorum is two, a majority. With two it is one: an acknowledgement needed both replicas, so either one alone holds every acknowledged record, and a segment placed on two brokers (a roll while a third was down) is sealed on its survivor instead of blocking its range for as long as the other is gone. With one replica it is one, which a dead primary cannot give.

That block is not a latch. The caller (Malachi.Cluster.HealCoordinator) is a periodic level-triggered loop, so the next pass re-evaluates: as soon as enough answer again, the seal is emitted and writing rolls to a new segment on its own. If they never return, the range stays blocked, which is the CP choice, and recovering it is a deliberate operator decision rather than something this policy takes on the operator's behalf.

The probing itself lives in the caller: candidates/3 names the segments to probe, and plan/5 turns the probe results into commands, so the policy stays pure and testable without processes.

A copy that failed

A replica does not have to die for its copy to be lost to a segment. When a storage operation fails, Malachi.Cluster.ReplicationServer takes that copy out of service for good (it never writes it again, and answers every request for it with an error), and the segment can no longer count on it. NorthGuard treats that exactly like a failed broker, "we just seal it, make a new one, move the producers over", and so does this policy: a segment with a FAILED copy is a candidate too, whether the copy is the primary's or a follower's, with the caller learning which copies failed from Malachi.Cluster.ReplicationServer.failed_segments/3 (failure_probes/2 says whom to ask).

The argument above carries over unchanged, because a failed copy is removed from the answers just as a dead primary is: it is never probed, never fenced, and never becomes the head. The consequence carries over too: without the failed copy the seal quorum must still answer, so one failed copy seals the segment on the others at three replicas and at two, while at one the range stays blocked until the failed server restarts.

Live replicas below the quorum

A primary that is alive does not make a segment writable on its own: every append needs the acknowledgement quorum. A segment whose live, non-failed replicas fall below it (a follower gone from a segment placed on two brokers) refuses every write while its primary looks healthy, so it is a candidate too, and it is sealed on whoever answers under the same seal quorum.

Summary

Types

Copies that failed in storage, as {segment_id, replica} pairs.

What one replica durably holds for a segment: its end offset and byte size.

Probe results per segment: %{segment_id => %{replica_ref => probe}}.

Functions

Whether segment is a failover candidate against the live broker set and the failed copies: the rule candidates/3 applies to every segment, for a caller that has to decide again about one after learning more than the membership view said (a replica it thought gone answered after all).

The active segments whose primary is dead, that have a failed copy, or whose live, non-failed replicas are below the acknowledgement quorum, each with the live, non-failed replicas worth probing. The caller probes these and feeds the results to plan/5. Sorted, so a pass is deterministic.

Whom to ask which copies failed: every live replica of a segment, active or sealed, with the segments it holds, as [{replica, [segment_id]}], sorted. One entry per replica so the caller makes one batched call per broker, whatever the number of segments.

The seal commands for the probed segments, at now_ms (passed in so every replica applies the same timestamp, as the produce path already does for its own seals). A segment whose probes do not reach the seal quorum of its replica set is skipped: see the moduledoc on why that leaves the range blocked rather than risking acknowledged data. Returns a sorted (deterministic) list.

How many answers a seal needs for a segment on replica_set: n - q + 1, the fewest replicas that share one with every acknowledgement quorum q (see the moduledoc).

Whether answered replicas reach the seal quorum of replica_set, the condition under which a seal point can be trusted. Public because the caller fences and logs by the same rule.

Types

failed()

@type failed() :: MapSet.t()

Copies that failed in storage, as {segment_id, replica} pairs.

probe()

@type probe() :: {non_neg_integer(), non_neg_integer()}

What one replica durably holds for a segment: its end offset and byte size.

probes()

@type probes() :: %{optional(term()) => %{optional(term()) => probe()}}

Probe results per segment: %{segment_id => %{replica_ref => probe}}.

Functions

candidate?(segment, live, failed)

@spec candidate?(Malachi.Metadata.segment_meta(), MapSet.t(), failed()) :: boolean()

Whether segment is a failover candidate against the live broker set and the failed copies: the rule candidates/3 applies to every segment, for a caller that has to decide again about one after learning more than the membership view said (a replica it thought gone answered after all).

candidates(metadata, live_brokers, failed \\ MapSet.new())

@spec candidates(Malachi.Metadata.t(), [Malachi.Metadata.broker()], failed()) :: [
  {term(), [Malachi.Metadata.broker()]}
]

The active segments whose primary is dead, that have a failed copy, or whose live, non-failed replicas are below the acknowledgement quorum, each with the live, non-failed replicas worth probing. The caller probes these and feeds the results to plan/5. Sorted, so a pass is deterministic.

failure_probes(metadata, live_brokers)

@spec failure_probes(Malachi.Metadata.t(), [Malachi.Metadata.broker()]) :: [
  {Malachi.Metadata.broker(), [term()]}
]

Whom to ask which copies failed: every live replica of a segment, active or sealed, with the segments it holds, as [{replica, [segment_id]}], sorted. One entry per replica so the caller makes one batched call per broker, whatever the number of segments.

Sealed segments are asked about too, though they are never a failover candidate: a sealed copy that failed takes no seal, but it is a lost replica the metadata still lists, which Malachi.Cluster.SelfHealing replaces on another broker.

plan(metadata, live_brokers, probes, now_ms, failed \\ MapSet.new())

The seal commands for the probed segments, at now_ms (passed in so every replica applies the same timestamp, as the produce path already does for its own seals). A segment whose probes do not reach the seal quorum of its replica set is skipped: see the moduledoc on why that leaves the range blocked rather than risking acknowledged data. Returns a sorted (deterministic) list.

seal_quorum(replica_set)

@spec seal_quorum([Malachi.Metadata.broker(), ...]) :: pos_integer()

How many answers a seal needs for a segment on replica_set: n - q + 1, the fewest replicas that share one with every acknowledgement quorum q (see the moduledoc).

iex> sets = [[:a], [:a, :b], [:a, :b, :c], [:a, :b, :c, :d], [:a, :b, :c, :d, :e]]
iex> Enum.map(sets, &Malachi.Cluster.Failover.seal_quorum/1)
[1, 1, 2, 2, 3]

seal_quorum?(answered, replica_set)

@spec seal_quorum?(non_neg_integer(), [Malachi.Metadata.broker(), ...]) :: boolean()

Whether answered replicas reach the seal quorum of replica_set, the condition under which a seal point can be trusted. Public because the caller fences and logs by the same rule.