Skip to content

Conversation

@ktoso
Copy link
Member

@ktoso ktoso commented Jan 17, 2020

Re-approaching the leader actions with unit tests rather than end to end ones first.

Quite WIP still, more progress on monday.
This same style to be done for upNumbers (or however I rephrase them, started on them for "oldest" and disliked it somehow...)

import NIOSSL
import XCTest

final class ClusterLeaderActionsClusteredTests: ClusteredNodesTestBase {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file was just renamed, the new test is ClusterLeaderActionsTests

@ktoso
Copy link
Member Author

ktoso commented Jan 18, 2020

Well, nice to see this green :)

Will give it another skim after the ski trip.
Attempting the "In for a penny, in for a pound." strategy with fixes here, though have to look at the specific failures + prioritize the stuffs for scheduler

@ktoso
Copy link
Member Author

ktoso commented Jan 27, 2020

To be fully hardened, this depends on #400 and #401

@ktoso
Copy link
Member Author

ktoso commented Jan 28, 2020

Time to rebase!

@yim-lee
Copy link
Member

yim-lee commented Jan 28, 2020

🤞

@ktoso
Copy link
Member Author

ktoso commented Jan 30, 2020

And we're green :-) Fixed the wrongly pushed sample...

I realize review probably is hard for this one -- not expecting a full review really. But I'll point out some points that could be worth a look / read. I plan to write cluster documentation which will be more important and easier to follow in terms of what semantics do / we want to offer :-)

if settings.cluster.enabled {
self.log.info("Actor System Settings in effect: Cluster.autoLeaderElection: \(self.settings.cluster.autoLeaderElection)")
self.log.info("Actor System Settings in effect: Cluster.downingStrategy: \(self.settings.cluster.downingStrategy)")
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kind of like this, helps a lot to realize what mode the cluster is in and what to expect of it...

Open to ideas if this is too loud or nice

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or maybe allow turning it off?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's nice. Only on system start-up so shouldn't be too noisy.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 ok, thanks!


case .snapshot(let snapshot):
Cluster.Membership.diff(from: .empty, to: snapshot).changes.forEach { change in
Cluster.Membership._diff(from: .empty, to: snapshot).changes.forEach { change in
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found diffing to not be super correct, the "change" style APIs are the correct and well tested ones.

So this will be internal kind of.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We only used it here btw from "empty" so it works fine. Will revisit it though

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

var s = self
s.state.removeValue(forKey: replicaId)
return s
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need this since when we remove members completely from membership we need to also prune the seen tables.

So a table like:

A: A@1 B@5
B: A@1 B@5

when we remove B must become

A: A@1

so we remove the entire vector that B owned, but also prune it from A's vector.

///
/// Used to guarantee phrases like "all nodes have seen a node A in status S", upon which the Leader may act.
struct Gossip {
struct Gossip: Equatable {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useful for testing

/// The version vector of this gossip and the `Membership` state owned by it.
var version: VersionVector {
self.seen.table[self.owner]! // !-safe, since we _always)_ know our own world view
self.seen.underlying[self.owner]! // !-safe, since we _always_ know our own world view
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we never remove "us" from a gossip, even merges which "remove" are protected from that, which is why the mergeForward takes myself so we never remove "us" from a membership.

The ONLY place where a .removed member may be stored is on that member itself, other members would remove and tombstone it. We want to store it so we know that "damn, not only am I down. Should not really happen in many cases though

/// Sequence number at which this node was moved to `.up` by a leader.
/// The sequence starts at `1`, and 0 means the node was not moved to up _yet_.
public var upNumber: Int?
public var _upNumber: Int?
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: they don't work just yet, but will shortly, This will enable more stable singletons (that move around less in face of nodes dying and leaders moving around)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

case .joining, .up, .leaving:
return Member(node: self.node, status: .down)
case .down, .removed:
return self
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a helper so we don't cause a removed -> down accidentally

/// Note that moving only happens along the lifecycle of a member, e.g. trying to move forward from .up do .joining
/// will result in a `nil` change and no changes being made to the member.
public mutating func moveForward(_ status: Cluster.MemberStatus) -> Cluster.MembershipChange? {
public mutating func moveForward(to status: Cluster.MemberStatus) -> Cluster.MembershipChange? {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

function enforces that we only move "forward" in status

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if no change, we return nil.

This is super important (!), since whenever we apply a change from gossip or locally, we get a change -- and if it's NOT nil then we fire Cluster.Event -- people should not be getting events if the from/to is the same :-)

Copy link
Member

@yim-lee yim-lee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code very nicely commented throughout. 🙇‍♀

fatalError("Received fork busy response from an unexpected fork: \(fork)! Already in hand: \(inHand), and pending: \(pending)")

// Ignore others...
// Ignore others...
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

meh, auto-formatting does this

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea... since it kind of is in the "previous case" meh

}

/// Prune any trace of the passed in replica id.
///
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can remove this line

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes thank :)

case .some(let locallyKnownTo) where locallyKnownTo.status.isDown:
// we have NOT removed it yet, but it is down, so we ignore it
return .init(causalRelation: causalRelation, effectiveChanges: [])
case .none where Cluster.MemberStatus.down <= incomingOwnerMember.status:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be >=? i.e., incomingOwnerMember is down or worse?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you're right! Thanks! I rewrote as incomingOwnerMember.status.isAtLeastDown

// TODO: consider doing the same for .same?
return .init(causalRelation: causalRelation, effectiveChanges: [])
changes = []
// self.seen.merge(selfOwner: self.owner, incoming: incoming)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete? and the pprint below?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks!

get {
self._latestGossip
}
// ---------------------
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

intended to keep?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks will remove!

table.version(at: self.nodeB).shouldEqual(VersionVector.parse("B:1 A:1", nodes: self.allNodes))
table.version(at: self.nodeC).shouldBeNil()
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

A: A@5 B@5 C@6
B: A@5 B@10 C@6
C: A@5 B@5 C@6
""",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it weird for me to think this looks cute? 🤣

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like it a lot as well 😻

These tests were insanity without this DSL, now it's super clear to follow <3

gossip.seen.version(at: self.nodeB).shouldEqual(expected.seen.version(at: self.nodeB))
gossip.seen.version(at: self.nodeC).shouldEqual(expected.seen.version(at: self.nodeC))
// gossip.seen.shouldEqual(expected.seen)
// gossip.membership.shouldEqual(expected.membership)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thansk! was for debugging, sometimes hard to "visually diff" :)


pinfo("Gossip converged on all \(gossips.count) members, after \(gossipSend) (individual) sends")
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<3

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:-)

/// This ordering somewhat unusual, however always consistent and used to select a leader -- see `LowestReachableMember`.
public static let lowestAddressOrdering: (Cluster.Member, Cluster.Member) -> Bool = { l, r in
l.node < r.node
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved from ad hoc impl into a named one; this is how we select leaders

/// managed by a leader action, rather than (as .down is) be possible to invoke by any node at any time.
/// Note, that a removal also ensures storage of tombstones on the networking layer, such that any future attempts
/// of such node re-connecting will be automatically rejected, disallowing the node to "come back" (which we'd call a "zombie" node).
case removed
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note what .removed means :)


return true
}
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very on purpose manually implemented, see docs

/// Compute a diff between two membership states.
/// The diff includes any member state changes, as well as
internal static func diff(from: Cluster.Membership, to: Cluster.Membership) -> MembershipDiff {
// TODO: diffing is not super well tested, may lose up numbers
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

has a ticket #415

public internal(set) var fromStatus: Cluster.MemberStatus?
public let toStatus: Cluster.MemberStatus

init(member: Cluster.Member, toStatus: Cluster.MemberStatus? = nil) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ticket : #418


var leadershipActions: [LeaderAction] = []
leadershipActions.append(contentsOf: collectMemberUpMoves())
leadershipActions.append(contentsOf: collectDownMemberRemovals())
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The order of those is important, such that we first signal ups and then removals -- such that if any "i will take over for the now dead node" MAY consider the newly added to up nodes

get {
self._latestGossip
}
// ---------------------
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO remove

// KEEP VERSION >>> \(newValue.seen.version(at: self.myselfNode))
// NOW: \(self._latestGossip)
// NEW: \(newValue)
// """)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO remove all this

self._latestGossip = newValue
} else {
self._latestGossip = newValue.incrementingOwnerVersion()
precondition("\(self._latestGossip.membership)" != "\(newValue.membership)", "WHY! ARE THOSE EQUAL: \(reflecting: self._latestGossip.membership) ||||| \(reflecting: newValue.membership)")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is debugging, remove

}

if change.isAtLeastDown || change.isRemoval || change.isReplacement {
if change.isAtLeastDown {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

easier to read now :)

proto = .down
case .removed:
proto = .removed
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We fixed the order of those, so leaving is before down, not after -- adjusted all places where we use it to follow the same order.

[
"swim/membersToPing": "\(self.membersToPing)",
"swim/protocolPeriod": "\(self.protocolPeriod)",
"swim/timeoutSuspectsBeforePeriod": "\(self.timeoutSuspectsBeforePeriod)",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helps in debugging, by looking at this number we know which should become unreachable

remoteControl.sendUserMessage(type: Message.self, envelope: Envelope(payload: .message(message)), recipient: self.address)
} else {
pprint("no remote control!!!! \(self.address)")
self.system.log.warning("[SWIM] No remote control, while sending to: \(self.address)")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bug we'll fix here: #382


// TODO: if we have seen tables, we can use them to bias the gossip towards the "more behind" nodes
context.log.trace("Sending gossip to \(target)", metadata: [
context.log.info("Sending gossip to \(target)", metadata: [
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO make trace again, too loud logs

p += " Serializer (id:\(id)) key:\(key) = \(self.serializers[id], orElse: "<undefined>")\n"
}
print(p)
self.log.debug("\(p)")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so the printout gets captured in tests

func detailedMessage(got it: Any, unexpectedEqual: Any) -> String {
let msg = "[\(it)] does equal: [\(unexpectedEqual)]\n"
return self.detailedMessage(msg)
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since the message was wrong before for "not" checks

import XCTest

// "Get down!"
final class DowningClusteredTests: ClusteredNodesTestBase {
Copy link
Member Author

@ktoso ktoso Jan 30, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The most important test here 👀

It excercises everything -- leaders moving up, down, removing, leaders moving to other nodes. Failure detectors kicking in, merging .down observations form other nodes etc.

final class DowningClusteredTests: ClusteredNodesTestBase {
enum NodeStopMethod {
case leaveSelf // TODO: eventually this one will be more graceful, ensure others see us leave etc
case leaveSelfNode // TODO: eventually this one will be more graceful, ensure others see us leave etc
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


// ==== ------------------------------------------------------------------------------------------------------------
// MARK: Merging gossips

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests explain the best how merging observations work 👍

gossip.converged().shouldBeTrue()
}

// FIXME: we should not need .joining nodes to participate on convergence()
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is #412

@ktoso ktoso changed the title =cluster #52 Towards removing nodes by leader when .down seen by everyone =cluster #52 Leader actions on convergence, including .removals Jan 30, 2020
@ktoso
Copy link
Member Author

ktoso commented Jan 30, 2020

🎉 🎉 🎉

Now I'm excited to put through some testing using the sensors app, then more and more integration and terrible edge case tests 👍

@ktoso ktoso merged commit e7265ef into apple:master Jan 30, 2020
@ktoso ktoso deleted the wip-removal-moves-by-leader branch January 30, 2020 07:53
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.

Leader actions only on convergence Move nodes in membership to removed when ready to do so

2 participants