Skip to content

Backport gossip removal tombstones to v1.5 - #8723

Merged
Aaronontheweb merged 3 commits into
akkadotnet:v1.5from
Aaronontheweb:backport/8484-to-v1.5
Oct 2, 2026
Merged

Aaronontheweb merged 3 commits into
akkadotnet:v1.5from
Aaronontheweb:backport/8484-to-v1.5

Conversation

@Aaronontheweb

Copy link
Copy Markdown
Member

Backports #8484 to v1.5 as a standalone cluster-safety change.

When stale gossip contains a member that another node has already removed, the current merge can resurrect that member. This change records removed UniqueAddress values as UID-scoped gossip tombstones, unions them during concurrent/equal merges, filters tombstoned members, serializes them in additive protobuf field 7, and prunes them after akka.cluster.prune-gossip-tombstones-after (24 hours by default).

Rolling-upgrade behavior

The wire change is compatible in a mixed cluster:

  • old nodes ignore protobuf field 7
  • upgraded nodes interpret an absent field 7 as an empty tombstone set
  • UID scoping allows a restarted process at the same host and port to join normally

The protection is fully effective after all nodes are upgraded. While old nodes remain, they neither create nor preserve tombstones; a causally newer gossip from an old node can therefore supersede an upgraded node's tombstones. Removals that occur during the mixed-version window are not guaranteed to remain protected.

v1.5 adaptation

CsCheck 4.8 supports .NET 8+, so the new property-test package and property-test sources are scoped to the modern test target. The ordinary gossip, configuration, and serializer compatibility tests continue to compile for .NET Framework 4.8. Serializer tests also use the v1.5 SerializerWithStringManifest API.

Validation

  • 79 targeted gossip, tombstone, configuration, serializer, and property tests passed on .NET 10
  • Cluster API approvals passed 5/5
  • Akka.Cluster builds with warnings as errors for netstandard2.0 and .NET 6
  • the .NET Framework 4.8 test assembly builds with warnings as errors
  • git diff --check and Slopwatch changed-file analysis pass

The local Mono VSTest host could not negotiate its test protocol within 180 seconds, so the hosted Windows job remains the execution evidence for the .NET Framework tests.

…embers (akkadotnet#8484)

* Akka.Cluster: add removal tombstones to Gossip and consult them when merging

A member status on its own cannot tell "this member was removed" apart from "this
node has not heard about it yet". Merge had only the status to go on, so a member
the leader had removed was put back whenever a lagging peer still held it as
Leaving. The resurrected member is dead, so it can never mark itself seen, and
convergence stops for good.

Gossip now carries a tombstone per removed node - keyed by UniqueAddress, stamped
with the epoch milliseconds of the removal. That is positive evidence a removal
happened, and it travels with the gossip so any peer can apply it.

- Gossip.RemoveAll strips a node from members, seen, reachability and the vector
  clock and records its tombstone in one step, so no gossip that breaks the
  invariants is ever built.
- Gossip.Merge unions tombstones first, then prunes the merged vector clock by
  them. Union alone is not enough: a clock entry for a removed node is resurrected
  by the merge exactly the way the member is.
- Gossip.MergeTombstones unions tombstones without touching anything else, for the
  gossip-reception branches that pick a whole gossip as the winner.
- Gossip.PruneTombstones drops expired entries and returns the same instance when
  it drops nothing, so a caller can skip the update by reference check.
- Member.PickHighestPriority gains a tombstone-aware overload. The one-sided drop
  condition is widened with OR, not replaced: every member dropped before is still
  dropped, and no input keeps a member the old code dropped.
- AssertInvariants now requires members and tombstones to be disjoint. Merge picks
  a two-sided member by status alone, which is only correct while that holds.

Timestamps order nothing; they only decide when a tombstone expires. Union keeps
the later timestamp on collision, so two nodes merging the same pair of gossips in
opposite order reach the same state.

* Akka.Cluster: put gossip tombstones on the wire

Adds `repeated Tombstone tombstones = 7` to message Gossip, plus the Tombstone
message itself. Field 7 was the one vacant slot in Gossip - allAppVersions already
sits at 8 - so nothing needs renumbering. Codegen runs at build time; there is no
checked-in generated file to update.

The serializer builds its address table from members only, and a tombstoned node
is by definition not a member. Tombstone addresses are appended to that table
after the member loop, so a tombstone can index into it like everything else.
Getting this wrong is silent: it produces tombstones pointing at other nodes'
addresses rather than an error.

Gossip written before this field decodes to an empty tombstone set, which reduces
the merge to its previous status-only rule.

* Akka.Cluster: write tombstones on removal and expire them on the leader

The leader records a tombstone for every node it removes, on both removal paths -
unreachable Down/Exiting members and confirmed-Exiting members. Both already ran
through the same block in LeaderActionsOnConvergence; that block now calls
Gossip.RemoveAll instead of deriving members, seen, reachability and the vector
clock separately.

ReceiveGossip unions tombstones on all four comparison branches. Only the
concurrent branch merges; the other three pick one whole gossip as the winner and
would otherwise drop the loser's tombstones, letting removals decay out of the
cluster over time. That failure mode presents as the original bug returning
intermittently, which no unit test would catch.

Expired tombstones are dropped on the leader at the end of the same method.
Pruning has to run on converged ticks where nothing else changed, so it sits
outside the change guard: the method now computes the updated gossip - or the
local one when nothing changed - prunes it, and publishes only when the result
differs from the local gossip by reference. PruneTombstones returns the same
instance when it drops nothing, which is what makes that check work. Without it
every converged leader tick would bump the vector clock and reset the seen table,
and the cluster would never sit still.

Retention is akka.cluster.prune-gossip-tombstones-after, default 24h. The bound
that matters is how long a gossip carrying the stale member can survive before it
merges back in, and under a partition that is unbounded - so the value is set
against partition length, not bandwidth. A partition outlasting the window
re-opens the hole silently.

UpdateLatestGossip now records the invariant it carries: every change this node
makes to the gossip goes through it and bumps the vector clock, which is what lets
peers tell whose removal set is newer.

* Akka.Cluster: tests for gossip removal tombstones

GossipSpec
- The removal-resurrection case built straight from Gossip values: the leader has
  removed a member and holds its tombstone, a lagging peer still holds it as
  Leaving. The merge must not put it back, in either direction.
- The same setup without the tombstone, asserting the member IS kept. That case
  both proves the test above bites and pins the correct behaviour: with no evidence
  of a removal, a one-sided Leaving member may be a node the other side has not
  heard about yet, and dropping it would strand a live process.
- Union is commutative, and keeps the later timestamp on collision.
- The merged vector clock is pruned for every tombstoned node. Each side carries a
  clock entry for both removed nodes and a tombstone the other lacks; with only the
  member filter in place, this is the single test that fails.
- One-sided Down and Exiting members are still dropped with no tombstones present,
  so the OR did not turn into a replacement.
- A new incarnation at the same host and port survives its predecessor's tombstone.
- RemoveAll strips members, seen, reachability as observer and as subject, and the
  vector clock entry, while leaving other nodes' clock entries alone.
- Pruning boundary, and the same-instance identity that the leader's no-op check
  depends on.
- The three non-merge gossip comparison branches each keep both sides' tombstones,
  and the union refuses to adopt a tombstone for a node it still holds as a member.

ClusterMessageSerializerSpec
- Round trip with tombstones, over GossipEnvelope and over Welcome.
- A tombstoned address that shares a host and port with a member survives with its
  own UID, which is what catches a serializer resolving tombstones through the
  member address table. That bug is silent - it yields the wrong address, not an
  error.
- A gossip proto with field 7 cleared decodes to an empty tombstone set with
  members and version intact.

ClusterConfigSpec asserts the 24h default. The API approval files gain the two new
public members: the PickHighestPriority overload and ClusterSettings
.PruneGossipTombstonesAfter.

Also fixes Gossip.Prune, which rebuilt the gossip through the constructor and so
dropped tombstones on the pre-merge clock pruning path.

* Record gossip removal tombstones in the v1.6 breaking changes ledger

Wire addition to cluster gossip, the new retention setting, and the behavior
change: a removed member can no longer be resurrected by stale gossip.

* Akka.Cluster: property-based specs for gossip removal tombstones

The example specs pin the cases a human thought of. These sample the same code
over a few hundred to a few thousand random gossips per property and check laws:
merge is commutative, idempotent and associative; a tombstone always beats a
stale member; a removal never comes back over a random sequence of exchanges.

CsCheck 4.8.0, referenced from Akka.Cluster.Tests only. Fixed iteration counts,
so the class runs in about five seconds. A failure prints the seed to replay.

Generators draw from a bounded universe of six nodes, two of which share a host
and port and differ only by UID - the case that catches a serializer resolving a
tombstone through the member address table. Member statuses honour the allowed
transition table, tombstone timestamps come from a four value pool so collisions
are frequent, and every timestamp is passed in rather than read off the clock.

P1-P3   merge is commutative, idempotent and associative over members,
        tombstones with their timestamps, reachability and the vector clock.
P4-P6   a tombstoned node loses; a one-sided live member with no tombstone is
        kept; a one-sided Down or Exiting member is still dropped.
P7      disjointness, split by reception branch: the concurrent path lets the
        tombstone win, the winner-picked paths let the winner's member win.
P8      no tombstoned node keeps a vector clock entry through a merge.
P9      merged tombstones are the union, collisions take the later timestamp.
P10     PruneTombstones drops what expired and hands back the same instance
        otherwise, which the leader's no-op check depends on.
P11     RemoveAll writes a tombstone for every removal and strips it elsewhere.
P12     proto round trip preserves members, tombstones, reachability and clock.
P13-P14 random histories over three to five nodes exchanging gossip through the
        same branch selection ReceiveGossip uses, checked against a plain set of
        removed UIDs. Every node converges on that set, and no node ever loses a
        tombstone outside a prune.

Three properties carry a documented caveat where the law they check is narrower
than it first looks. Merge with itself also drops the clock entries of tombstoned
nodes, so P2 compares against that form. The one-sided drop for Down and Exiting
is not associative on its own, predating tombstones, so P3 draws non-terminal
statuses and lets tombstones carry the removals. Reachability merge breaks an
equal-version tie by argument order, so the two sides observe from disjoint
observer sets.

Each property counts the iterations that hit the case it is about and fails if
that count is too low, so none of them can pass by never generating anything
interesting.

* Akka.Cluster: keep only the winner's tombstones on the causally-ordered branches

ReceiveGossip unioned the receiver's tombstones into the winning gossip on all
four comparison branches, while PruneTombstones is called from one place - the
leader's LeaderActionsOnConvergence. A prune was therefore undone by the next
exchange with any peer, because peers never prune and always re-unioned. So
prune-gossip-tombstones-after reclaimed nothing, tombstones rode every gossip
message forever, and once one was past the window the leader looped on every
tick: prune, clock bump, seen reset, re-converge, peers hand it back, prune
again. That is exactly the never-settles failure the reference-equality guard in
PruneTombstones exists to prevent.

Tombstones are now unioned only where neither gossip descends from the other -
the equal-clock branch, and the concurrent branch inside Gossip.Merge. The two
branches that pick a strictly newer gossip keep the winner's tombstones and
nothing else. The winner is not behind on removals: a removal bumps the removing
node's own clock entry, so a gossip that dominates the loser's clock descends
from every removal the loser knows about. The tombstones it does not carry are
the ones its own leader pruned, and honouring those prunes is the point.

The branch selection moved out of ReceiveGossip into
ClusterCoreDaemon.SelectWinningGossip, unchanged apart from the two lines above.
The property specs now call it instead of re-implementing it - they had their own
copy of the branches, so deleting the production change left them all green.

Three new properties:

P15  a tombstone pruned on a converged tick does not come back. The pruning node
     stamps the prune the way UpdateLatestGossip does, which puts it strictly
     ahead of every peer, so every exchange afterwards takes a winner-picked
     branch - the branch that used to hand the tombstones straight back.
P16  a simulated week of virtual time: sparse jumps forward, a constant per-node
     clock offset up to five minutes, and a prune tick run by a different drawn
     node each time, measured against the shipped prune-gossip-tombstones-after
     default rather than a cutoff invented for the test. Expired tombstones are
     reclaimed and stay reclaimed, and no tombstone is dropped sooner than the
     window minus the widest clock disagreement.
P17  a removal holds when the node that recorded it is itself removed later.
     That is where Merge prunes the clock entry that recorded the removal, so it
     is the one place a gossip could come out strictly newer than a tombstone
     carrier without having descended from the removal.

P15 and P16 both fail if the union is restored on the Before and After branches.
P17 documents a resurrection the model can reach and a cluster cannot, because
removing a member is a leader action and the leader needs convergence first;
the same history resurrects the member with the union restored, so it is not
something the union was buying.

Also in the suite: the seen table joins the equivalence helper; reachability is
built from one shared op history per observer with each side replaying a prefix
of it, so sides share observers at different versions and Reachability.Merge's
version arbitration is actually exercised; histories gained a status op so peers
hold a victim at mixed statuses while its tombstone propagates; every op asserts
the gossip carries no clock entry for a non-member, mirroring the "too many
vector clock entries" check; P4, P7c and P11 assert no reachability record
survives whose subject was removed; the serializer property gained a sample
where a tombstone key is also a member, which is the arm of the address-table
writer nothing else reached; coverage guards count iterations rather than
occurrences; and the properties whose inputs the Gossip constructor rejects under
AKKA_CLUSTER_ASSERT=on skip there instead of failing.

* ci: retrigger after superseded-run cancellations

* Akka.Cluster: fold tombstones into the Gossip constructor and document the file

Addresses two review comments on akkadotnet#8484.

Drop the four-argument Gossip constructor added by the tombstone work.
Gossip is internal, so there is no API risk in changing the existing
three-argument constructor instead: tombstones becomes an optional
parameter that defaults to no tombstones. Every existing call site
compiles unchanged, including the ones that pass tombstones positionally.

Replace all 62 TBD placeholders in Gossip.cs with docs that say what each
member actually does. The delegating constructors carry a one-line summary
noting their defaults and inherit the rest from the primary. Notable
details now written down: GetMember hands back a Removed placeholder for a
node that is not in the ring, AddMember is keyed by address so it will not
replace a member that differs only in status, YoungestMember treats a
member that is not up yet as up-number zero, and Prune clears a node's
vector clock entry without touching members or tombstones.
@Aaronontheweb
Aaronontheweb disabled auto-merge October 2, 2026 20:44
@Aaronontheweb
Aaronontheweb merged commit 5aeed9a into akkadotnet:v1.5 Oct 2, 2026
2 of 11 checks passed
@Aaronontheweb
Aaronontheweb deleted the backport/8484-to-v1.5 branch October 2, 2026 20:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant