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
@type failed() :: MapSet.t()
Copies that failed in storage, as {segment_id, replica} pairs.
@type probe() :: {non_neg_integer(), non_neg_integer()}
What one replica durably holds for a segment: its end offset and byte size.
Probe results per segment: %{segment_id => %{replica_ref => probe}}.
Functions
@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).
@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.
@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.
@spec plan( Malachi.Metadata.t(), [Malachi.Metadata.broker()], probes(), integer(), failed() ) :: [ Malachi.Metadata.command() ]
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.
@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]
@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.