Malachi. Cluster. MemberIncarnation
(malachi v0.22.7)
View Source
The incarnation a node resumes at after a restart, kept on disk so a restarted member outranks everything its peers still remember about the member it was.
The problem this solves
A member owns its incarnation, and SWIM's merge is a join on {incarnation, rank} that ignores an
equal or lower pair. A node that restarts from scratch therefore announces itself below whatever
its peers retained, and every peer ignores the announcement and keeps the old record: the old status
and, which is what makes this more than a curiosity, the old attributes.
So a node that comes back on a build advertising fewer capabilities is still remembered as
advertising the old set (Malachi.Cluster.Capabilities), and a cluster flag can be switched on over
a node that cannot honour it. The same staleness silently feeds rack-aware placement, which reads the
same attributes.
An incarnation rises only on a refutation or a deliberate attribute change, so it cannot be derived from anything the node already keeps. It has to be remembered.
How
A single decimal number in malachi.incarnation, at the root of the log data directory beside
malachi.format, written the same durable way (Malachi.Storage.FormatMarker): a temporary file with
:sync, a rename over the target, then an fsync of the directory, because a rename is a change to the
directory that the file's own fsync does not persist.
The number is a ceiling, not the current value. reserve/3 starts the node one above the recorded
ceiling and immediately records a ceiling a whole block higher, so the node can raise its incarnation
that many times without touching the disk again. Only crossing the block writes, through extend/3.
That keeps the write off the refutation path, which is where an fsync would sit in the way of the
failure detector.
The wall clock is a floor under all of it
A reservation starts above the recorded ceiling and above the current second (seed/0). The clock
is not a source of incarnations, it is a floor: the recorded ceiling still wins whenever it is higher,
so a clock moving backwards can never lower a node.
It is there for the boot that has no ceiling to read. A node upgraded from a build that never kept this file has no record, while its peers may remember it well above zero, because the old build raised its incarnation in memory on every refutation and every attribute change. Starting that node at 1 puts it below what they hold, which is the permanent staleness described below, and it is not something an operator can repair by hand: the number needed is whatever the peers happen to remember, which is not visible from the node being started. An incarnation counts refutations, so it reaches tens or hundreds; seconds since 1970 do not. Seeding from the clock therefore clears every value an older build could have reached, and asks nothing of whoever runs the upgrade.
A missing file and a damaged one are not the same
Only a genuinely absent file reads as ceiling 0, which is a node's first boot. A file that exists but cannot be read or parsed is an error, and the node refuses to start on it.
That distinction is the whole safety of this module, because a node that resumes below what its peers remember is never corrected. Its announcement loses the merge and is ignored; the peers keep the old record, including the old attributes; and since the node is answering their pings they have no reason to suspect it, so no refutation is ever provoked to lift it. The staleness is permanent, not a round. Starting from a guess is therefore not a lesser evil than not starting at all.
Summary
Types
The value this node starts at, and the ceiling it may reach before it must write again.
Functions
Records a ceiling a block above incarnation, for a node that has used up the block it reserved.
The path the ceiling is kept at, inside dir.
The ceiling recorded in dir.
Claims the next block of incarnations for this node in dir, starting above the recorded ceiling and
above seed.
The floor a reservation starts from when the recorded ceiling is lower: seconds since the epoch.
Types
@type reservation() :: %{start: pos_integer(), ceiling: pos_integer()}
The value this node starts at, and the ceiling it may reach before it must write again.
Functions
@spec block() :: pos_integer()
The size of the block reserve/3 hands out.
@spec extend(Path.t(), pos_integer(), pos_integer()) :: {:ok, pos_integer()} | {:error, term()}
Records a ceiling a block above incarnation, for a node that has used up the block it reserved.
Answers the new ceiling, or {:error, reason}. This module only reports the failure; what to do about
it belongs to the caller, and Malachi.Cluster.MembershipServer stops the node so it comes back and
reserves a block it can trust. Carrying on would leave it announcing numbers above the last one on
disk, and its next restart would then resume below what peers remember, where nothing corrects it: see
the moduledoc.
The path the ceiling is kept at, inside dir.
@spec read(Path.t()) :: {:ok, non_neg_integer()} | {:error, term()}
The ceiling recorded in dir.
{:ok, 0} when there is no file, which is a node that has never run or one upgraded from a build
that did not keep this file. The two are indistinguishable here, which is why reserve/3 puts the
clock floor under the answer rather than trusting it. {:error, {:damaged, content}} when a file
exists but does not hold one non-negative integer, and {:error, {:io, posix}} when it exists and
cannot be read. Both errors stop the node rather than resetting it: see the moduledoc.
@spec reserve(Path.t(), pos_integer(), non_neg_integer()) :: {:ok, reservation()} | {:error, term()}
Claims the next block of incarnations for this node in dir, starting above the recorded ceiling and
above seed.
Answers {:ok, %{start: start, ceiling: ceiling}} once the new ceiling is durably recorded, so a
crash immediately after cannot hand the same numbers out twice. {:error, reason} when the file
cannot be written, which the caller decides what to do about: this module never guesses.
seed defaults to seed/0, the current second, and is a floor rather than a value: the recorded
ceiling wins whenever it is higher. Tests pass 0 to keep the numbers readable. The moduledoc says
why an upgraded node needs the floor.
@spec seed() :: non_neg_integer()
The floor a reservation starts from when the recorded ceiling is lower: seconds since the epoch.
Never negative, so a clock set before 1970 costs the floor rather than the reservation.