Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Respond to standup at login correctly. #1877

Merged
merged 4 commits into from
Sep 30, 2024

Conversation

tylerkaraszewski
Copy link
Contributor

@tylerkaraszewski tylerkaraszewski commented Sep 23, 2024

Details

See investigation in comments here: https://github.com/Expensify/Expensify/issues/426227

When we connect to a peer, if we're STANDINGUP we intend to send a STATE message indicating that the remote node should approve or deny our standup:

void SQLiteNode::_onConnect(SQLitePeer* peer) {
SASSERT(peer);
SASSERTWARN(!peer->loggedIn);
// Send the LOGIN
PINFO("Sending LOGIN");
SData login("LOGIN");
login["Priority"] = to_string(_priority);
login["State"] = stateName(_state);
login["Version"] = _version;
login["Permafollower"] = _originalPriority ? "false" : "true";
_sendToPeer(peer, login);
// If we're STANDINGUP when a peer connects, send them a STATE message so they know they need to APPROVE or DENY the standup.
// Otherwise we will wait for their response that's not coming,and can eventually time out the standup.
if (_state == SQLiteNodeState::STANDINGUP) {
SData state("STATE");
state["StateChangeCount"] = to_string(_stateChangeCount);
state["State"] = stateName(_state);
state["Priority"] = SToStr(_priority);
_sendToPeer(peer, state);
}
}

However, because the LOGIN and STATE messages are sent back to back, they will always have the same state (which is STANDINGUP).

This means that when processing the STATE message we'll always hit this code:

if (from == to) {
// No state change, just new commits?
PINFO("Peer received new commit in state '" << stateName(from) << "', commit #" << message["CommitCount"] << " ("
<< message["Hash"] << ")");
} else {

Rather than this code:

SData response("STANDUP_RESPONSE");

We should probably send STANDUP_RESPONSE in response to LOGIN when the node is STANDINGUP and remove the second STATE message here.

This will fix the race condition where a late login during standup prevents the cluster from forming which happens because of this line:

_forkedFrom.clear();

However, I think that just puts us in a different bad state in the case we are forked from the node that does the late connect. Now it will respond to the LOGIN with a STANDUP_RESPONSE but this will be a DENY which is just as bad.

I think we should adjust the logic to not clear the _forkedFrom list until we are actually LEADING or FOLLOWING. I can imagine some scenario where clearing this in STANDINGUP gets us to reset a node that was forked and no longer is, i.e., it was just restored from backup, and it was the final node required to reach quorum. In this case, we would still not form a cluster, we'd need to restart the node that is attempting to stand up.

Thoughts on this?

EDIT: Looking at the code, we won't actually DENY due to a hash mismatch, only if we think some other node is leading. So a node that's forked from us (but hasn't realized that yet) can still approve the standup, and it will abstain if it does realize it's forked.

NOTE: The code in _sendStandupResponse is exactly the block deleted from _onMESSAGE with no changes except indentation.

Fixed Issues

Fixes https://github.com/Expensify/Expensify/issues/426227

Tests


Internal Testing Reminder: when changing bedrock, please compile auth against your new changes

@tylerkaraszewski tylerkaraszewski self-assigned this Sep 23, 2024
@tylerkaraszewski tylerkaraszewski changed the title [WIP] Respond to standup at login correctly. Respond to standup at login correctly. Sep 23, 2024
Copy link
Member

@rafecolton rafecolton left a comment

Choose a reason for hiding this comment

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

The code change is simple enough and LGTM 👍 Have some questions.

However, I think that just puts us in a different bad state in the case we are forked from the node that does the late connect. Now it will respond to the LOGIN with a STANDUP_RESPONSE but this will be a DENY which is just as bad.

EDIT: Looking at the code, we won't actually DENY due to a hash mismatch, only if we think some other node is leading. So a node that's forked from us (but hasn't realized that yet) can still approve the standup, and it will abstain if it does realize it's forked.

This is not actually a problem due to the EDIT: portion, right?

I think we should adjust the logic to not clear the _forkedFrom list until we are actually LEADING or FOLLOWING. I can imagine some scenario where clearing this in STANDINGUP gets us to reset a node that was forked and no longer is, i.e., it was just restored from backup, and it was the final node required to reach quorum. In this case, we would still not form a cluster, we'd need to restart the node that is attempting to stand up.

Can you elaborate a bit and state if you still think this is necessary? I'm not understanding how the scenario above would cause us to not form a cluster

@cead22
Copy link
Contributor

cead22 commented Sep 24, 2024

This will fix the race condition where a late login during standup prevents the cluster from forming which happens because of this line:

I read everything a few times, but the one thing I don't understand is this scenario. Can you break it down?

Regardless, the change looks good to me and it seem like a simplification

Copy link
Contributor

@danieldoglas danieldoglas left a comment

Choose a reason for hiding this comment

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

Changes LGTM, just a few questions so I can better understand how bedrock works

Comment on lines +2796 to +2801
if (otherPeer->state == SQLiteNodeState::STANDINGUP || otherPeer->state == SQLiteNodeState::LEADING || otherPeer->state == SQLiteNodeState::STANDINGDOWN) {
// We need to contest this standup
response["Response"] = "deny";
response["Reason"] = "peer '" + otherPeer->name + "' is '" + stateName(otherPeer->state) + "'";
break;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I know this is not related to the changes you're applying, but just wanted to understand this part. In the code above, if a peer is trying to standup and we're standing down, we add a warn (line 2762) but don't really set anything in the response. But in this block, if we see another peer standing down, we actually deny it.

Why do we have that different behavior between both?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think this is just an oversight where we've used a definition of leading in this case that includes standingup and standingdown. We could probably exclude it here as well and only use deny if the peer is standingup or leading.

Comment on lines -1821 to -1830

// If we're STANDINGUP when a peer connects, send them a STATE message so they know they need to APPROVE or DENY the standup.
// Otherwise we will wait for their response that's not coming,and can eventually time out the standup.
if (_state == SQLiteNodeState::STANDINGUP) {
SData state("STATE");
state["StateChangeCount"] = to_string(_stateChangeCount);
state["State"] = stateName(_state);
state["Priority"] = SToStr(_priority);
_sendToPeer(peer, state);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Since we're not using any locks here, would it be possible that we have a state while sending login and another when sending state?

And in that case, considering that the _changeState would have been called somewhere else, would everything still work out fine since _changeState also sends a STATE message?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It would have been a bug if state changed between these two messages, though that couldn't happen any more since this change as we only send one now, right?

But state couldn't change between these two messages as only the sync thread can change state, and only the sync thread calls _onConnect.

Does that adequately answer your question?

Copy link
Contributor

Choose a reason for hiding this comment

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

It does, thanks!

Copy link
Contributor

@chiragsalian chiragsalian left a comment

Choose a reason for hiding this comment

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

Im not sure if i 100% understand, but the code changes here LGTM. Is there some way to simulate postPoll and peer state changes in our vm?

@tylerkaraszewski
Copy link
Contributor Author

This is not actually a problem due to the EDIT: portion, right?

Yes, that seems to be the case, this isn't actually a problem.

Can you elaborate a bit and state if you still think this is necessary? I'm not understanding how the scenario above would cause us to not form a cluster

Ok, let me quote myself for quick reference:

I think we should adjust the logic to not clear the _forkedFrom list until we are actually LEADING or FOLLOWING. I can imagine some scenario where clearing this in STANDINGUP gets us to reset a node that was forked and no longer is, i.e., it was just restored from backup, and it was the final node required to reach quorum. In this case, we would still not form a cluster, we'd need to restart the node that is attempting to stand up.

I believe I intended the "edit" block to apply to this, meaning that we can ignore the "I think we should... " sentence. But let's clarify the rest.

Let's say we are 1.sjc, and we are connected to 1.lax. Neither node is forked from the other, but 1.sjc thinks it is forked from 2.sjc. There is no cluster, say all other nodes are offline.

When 2.sjc comes back up (and is no longer forked), does 1.sjc exclude it from approving the standup because it's forked? If so, we will never reach "LEADING" because we will never have our standup approved. However, if we clear the list of forked nodes at STANDINGUP, then we can count 2.sjc's approval because it is no longer forked.

I don't think this actually matters though, since we don't send DENY due to being forked.

@tylerkaraszewski
Copy link
Contributor Author

I read everything a few times, but the one thing I don't understand is this scenario. Can you break it down?
Regardless, the change looks good to me and it seem like a simplification

So the investigation for that was actually in this comment: https://github.com/Expensify/Expensify/issues/426227

2.sjc tries to stand up.
It does not get a response from 1.sjc
it goes searching.
It picks 1.sjc as a sync peer.
When it tries to sync, It notices it's forked from 1.sjc
This triggers a reconnect to 1.sjc
It tries to stand up again, because the rest of the cluster is available.
1.sjc reconnects after it starts standing up.
The whole thing repeats.

If we did not remove 1.sjc from the list of forked peers, we wouldn't try to choose it as a sync peer and reconnect to it.

I think that's complete.

@tylerkaraszewski
Copy link
Contributor Author

I've going to merge, feel free to leave more comments if anything was inadequately answered.

@tylerkaraszewski tylerkaraszewski merged commit 1ef3e2d into main Sep 30, 2024
1 check passed
@tylerkaraszewski tylerkaraszewski deleted the tyler-fix-connect-while-standing-up branch September 30, 2024 23:32
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.

6 participants