-
Notifications
You must be signed in to change notification settings - Fork 4k
Remove redundant SubchannelPicker refreshes in RoundRobinLoadBalancer #4607
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
Conversation
- Ensure active subchannel list and round-robin index is only regenerated/refreshed when it changes - Make it so that Subchannels exist in subchannels map iff their state != SHUTDOWN - Add EmptyPicker class since logic for this case is disjoint from the non-empty case
|
@zhangkun83 any interest in this change? |
carl-mastrangelo
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@dapengzhang0 can you take a look?
| private final Map<EquivalentAddressGroup, Subchannel> subchannels = | ||
| new HashMap<EquivalentAddressGroup, Subchannel>(); | ||
| // true when map contains at least one Subchannel in READY state | ||
| private boolean ready = false; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you can drop the = false as it is default.
per @carl-mastrangelo's review comment
|
Thanks @carl-mastrangelo. I noticed your PR #4643 has some bits in common with this - might it make sense for |
|
@carl-mastrangelo @dapengzhang0 sorry to bug, wondering if you might have a chance to look at this? |
carl-mastrangelo
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Initial round of comments, sorry for the delay. This code is complicated and take a lot of time to read.
| for (EquivalentAddressGroup addressGroup : removedAddrs) { | ||
| Subchannel subchannel = subchannels.remove(addressGroup); | ||
| subchannel.shutdown(); | ||
| update = update || isReady(subchannel); // no need to update if channel was already excluded |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't this missing for the addedAddrs ? i.e. why only update on removing addresses?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The main intention is to avoid calling updateBalancingState() in cases that won't result in any change to the Picker state, particularly when it already has active endpoints.
Any new addedAddrs are added in IDLE state and so will have no effect on the Picker state if already has at least one READY subchannel (and if it doesn't then ready will be false and so we update anyhow).
| if (stateInfo.getState() == SHUTDOWN && stickinessState != null) { | ||
| Ref<ConnectivityStateInfo> stateInfoRef = getSubchannelStateInfoRef(subchannel); | ||
| ConnectivityState stateBefore = stateInfoRef.value.getState(); | ||
| if (stateBefore == SHUTDOWN) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should be an error, as there should be no more events after the SC has shut down.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The state in the subchannel attribute is now set to SHUTDOWN when the EAG is removed in the handleResolvedAddressGroups callback (at the same time as subchannel.shutdown() is called and that it's removed from this LB's subchannels map - see line 220).
This is because from the load balancer's pov it makes more sense to consider it in a non-ready state as soon as the NameResolver reports it removed, rather than when a subsequent event is received that the shutdown is complete (i.e. here).
So this is really catching the "normal" SHUTDOWN state change following the subchannel's prior removal.
| if (newState == SHUTDOWN && stickinessState != null) { | ||
| stickinessState.remove(subchannel); | ||
| } | ||
| if (subchannels.get(subchannel.getAddresses()) != subchannel) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@zhangkun83 How can this case happen?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The balancer could shutdown a subchannel and create a new one for the same address because of churn on the address list. ManagedChannelImpl may still have state updates for the previous subchannel even after the new one is created. The balancer should ignore those updates.
| } | ||
| if (stateInfo.getState() == IDLE) { | ||
| if (newState == IDLE) { | ||
| subchannel.requestConnection(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@zhangkun83 isn't this also redundant? When a new channel is created the connection is already requested. What will calling it twice do? Also, won't this prevent channels from ever going idle?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
RR needs to actively maintain connections to all servers even in absence of RPCs. This line makes sure subchannel reconnects after the initial connections is broken. It's intentional for RR to keep subchannels from going idle. This doesn't stop the top-level channel from entering idle mode after a certain time without RPC.
| updateBalancingState(getAggregatedState(), getAggregatedError()); | ||
| stateInfoRef.value = stateInfo; | ||
| if (!ready || (newState == READY ^ stateBefore == READY)) { | ||
| updateBalancingState(null, null); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: we generally avoid using literals that are not obvious. IN this case, consider naming the literal nulls like:
updateBalancingState(/*connectivityState= */ null, /* status= */ null);
| private void updateBalancingState(ConnectivityState state, Status error) { | ||
| List<Subchannel> activeList = filterNonFailingSubchannels(getSubchannels()); | ||
| helper.updateBalancingState(state, new Picker(activeList, error, stickinessState)); | ||
| ready = !activeList.isEmpty(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The logic here is starting to get complicated, and it is no longer "obviously" correct. Can you please document the params?
| state = ready ? READY : getAggregatedState(); | ||
| } | ||
| SubchannelPicker picker = ready ? new Picker(activeList, stickinessState) : | ||
| new EmptyPicker(error != null ? error : getAggregatedError()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
there is a lot of conditional inline logic here which is hard to visually follow. For example, why does error take precedence over aggregated error? If the params are meant to be optional, it would be better expressed as an overload.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agree, I should restructure this to be more explicit/readable.
Some of it is to avoid unnecessary computation - for example getAggregatedState() is only applicable when the filtered activeList is empty, and not in the handleNameResolutionError() callback case. Similar thing for error - it only applies in the empty active list case, where it's determine by getAggregatedError() unless this is triggered by handleNameResolutionError() in which case the name resolver error is relayed.
| List<Subchannel> readySubchannels = null; | ||
| for (Subchannel subchannel : subchannels) { | ||
| if (getSubchannelStateInfoRef(subchannel).value.getState() == READY) { | ||
| if (isReady(subchannel)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@zhangkun83 isn't this going to be an expensive linear time operation? Why is the list scanned each time, rather than Subchannel state change?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do you mean by "each time"? This method is called only when Subchannel state changes.
zhangkun83
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please merge master to your branch since #4462 is in.
Instead of such an invasive change, have you considered just changing updateBalancingState() so that it saves the current picker, compares the new picker with the current one, and only calls helper.updateBalancingState() if they differ?
| if (newState == SHUTDOWN && stickinessState != null) { | ||
| stickinessState.remove(subchannel); | ||
| } | ||
| if (subchannels.get(subchannel.getAddresses()) != subchannel) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The balancer could shutdown a subchannel and create a new one for the same address because of churn on the address list. ManagedChannelImpl may still have state updates for the previous subchannel even after the new one is created. The balancer should ignore those updates.
| } | ||
| if (stateInfo.getState() == IDLE) { | ||
| if (newState == IDLE) { | ||
| subchannel.requestConnection(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
RR needs to actively maintain connections to all servers even in absence of RPCs. This line makes sure subchannel reconnects after the initial connections is broken. It's intentional for RR to keep subchannels from going idle. This doesn't stop the top-level channel from entering idle mode after a certain time without RPC.
| List<Subchannel> readySubchannels = null; | ||
| for (Subchannel subchannel : subchannels) { | ||
| if (getSubchannelStateInfoRef(subchannel).value.getState() == READY) { | ||
| if (isReady(subchannel)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do you mean by "each time"? This method is called only when Subchannel state changes.
|
Thanks @carl-mastrangelo @zhangkun83 for looking at this.
Yes this was an initial thought, but it seemed kind of wrong/wasteful for me to regenerate and compare the new picker in all these cases where it's easily known at the point of the change that it will have no impact on the picker state, i.e. in the majority of I will merge latest from master in, and have a go at making the logic simpler / easier to read. Part of the problem may actually be that I was trying to minimize the invasiveness by tweaking the existing structure. If that still isn't palatable then I agree just doing the picker comparison would be a much smaller change and will still achieve the primary goal. |
Conflicts: core/src/main/java/io/grpc/util/RoundRobinLoadBalancerFactory.java core/src/test/java/io/grpc/util/RoundRobinLoadBalancerTest.java
|
Ping @zhangkun83 |
|
Thanks for the patience @njhill. I am not excited about the added complexity. I would prefer doing the picker comparison. |
|
@njhill, are you still working on this? |
|
@zhangkun83 yes, apologies I was out travelling for a couple of days and bit behind with things, I will get back to it soon hopefully (if not this week then next week). |
|
No worries. Just checking the status on your side :-) |
Conflicts: core/src/test/java/io/grpc/util/RoundRobinLoadBalancerTest.java
|
@zhangkun83 I've now updated this to save the current picker but instead of doing a full re-generation and comparison of the READY subchannel list every time, it uses a readily-accessible determination of whether the list has changed or not. I've added some comments and restructured a little to make this clearer. It also more cleanly separates the different cases of a "ready" picker, an "empty" picker (which may or may not have an associated error), and a name resolution error. For example there is no need to touch the picker on name resolution errors as is currently done. Please let me know what you think! |
zhangkun83
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am fine with dividing the picker into two, but I still feel strong for creating and comparing the pickers. See my inline comments for my reasoning.
As for how to compare, we probably don't want to override equals() because the picker is stateful. Instead, I would create a util method areEquivalentPickers().
| if (!(currentPicker instanceof ReadyPicker)) { | ||
| currentPicker = new EmptyPicker(error); | ||
| } | ||
| helper.updateBalancingState(TRANSIENT_FAILURE, currentPicker); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's arguable whether a previously READY channel should turn TRANSIENT_FAILURE when receiving a name resolution error. Either way, the state should be consistent with the type of the picker.
If you want to keep the current ready picker, the state should also be READY too -- as this would be another behavior change, please do it in a separate PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@zhangkun83 unless I've made a mistake, this PR should contain zero behaviour change (apart from eliminating unnecessary shuffles of the picker index of course).
In particular the logic here is what the existing logic actually does if you unravel it. I.m.o. it's better for it to be explicit so that it's easier to reason about and decide whether the behaviour is appropriate (which I didn't think too much about).
Specifically in the existing code:
- The
ConnectivityState(hereTRANSIENT_FAILURE) is passed toupdateBalancingStateunconditionally, and isn't part of the generatedPicker's state at all Pickercompletely ignoreserrorif it has a non-empty list of (READY) subchannels- The name resolution error event by itself has no effect on the set of
READYsubchannels and therefore no effect on the generated picker unless it's already empty
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You are right about that the behavior is unchanged. My mistake.
| } | ||
|
|
||
| updateBalancingState(getAggregatedState(), getAggregatedError()); | ||
| updateBalancingState(activeListChanged || stickinessStateChanged); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While the logic is clear enough as it is, I still don't like the little addition of complexity. The list change handling is not in the RPC critical path, and the overhead of creating and discarding a new picker doesn't seem to be significant enough to justify the added code complexity, e.g., the boolean argument here passes ad-hoc information across function boundaries just because your logic has to be divided into multiple functions instead of being one -- not saying it's wrong, but I'd like to avoid such situation if possible.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, I will rework to always re-generate and compare the pickers.
|
@zhangkun83 now updated to do picker comparison, PTAL at your convenience! |
| } | ||
| } | ||
|
|
||
| private static boolean areEquivalentPickers(SubchannelPicker p1, SubchannelPicker p2) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are uncovered lines. You may want to expose it as VisibleForTesting and have it thoroughly tested.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I refactored this in the latest commit, moving the logic to a new private abstract class method RoundRobinPicker.isEquivalentTo() (to help make it clearer that it only applies to the private RRLB pickers and not SubchannelPickers in general).
| // initialize the Picker to a random start index to ensure that a high frequency of Picker | ||
| // churn does not skew subchannel selection. | ||
| int startIndex = random.nextInt(activeList.size()); | ||
| updateBalancingState(READY, new ReadyPicker(activeList, startIndex, stickinessState)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You never call getAggregatedState() when the state is READY. This shadows the return READY branch in that method (noticed by coveralls). Since getAggregatedState() is only called once now, probably better to move it into updateBalancingState().
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've now combined/collapsed both the getAggregatedState() and getAggregateError() methods into updateBalancingState().
| if (stateInfoRef.value.getState() == SHUTDOWN) { | ||
| // This is the case the shutdown was triggered by a name resolver removal, the channel | ||
| // shutdown state change logic was already triggered in handleResolvedAddressGroups(). | ||
| return; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This new branch is not covered in test. It doesn't seem necessary either, because the next branch doesn't change anything, and the following branch will just return.
| getSubchannelStateInfoRef(subchannel).value = | ||
| ConnectivityStateInfo.forNonError(SHUTDOWN); | ||
| if (stickinessState != null) { | ||
| stickinessState.remove(subchannel); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This branch is not covered.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've extended a unit test to cover this case - name resolver removal of a sticky subchannel.
| return false; | ||
| } | ||
| if (p1 instanceof EmptyPicker) { | ||
| return Objects.equal(((EmptyPicker)p1).status, ((EmptyPicker)p2).status); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please add a space following the closing parenthesis of class casting.
- remove now redundant check in handleSubchannelState - collapse getAggregatedState() and getAggregatedError() into handleBalancingState() - have both pickers extend new RoundRobinPicker, move areEquivalentPickers() logic into RoundRobinPicker.isEquivalentTo() - extend unit tests to cover some additional cases
0153447 to
b1a7935
Compare
|
@zhangkun83 thanks for the detailed review. Have made some updates which hopefully address your comments. One of the builds failed but I don't think it was code related. |
| private final Status status; | ||
|
|
||
| EmptyPicker(@Nullable Status status) { | ||
| this.status = status; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Making it non-Nullable, and using OK to indicate PickResult.withNoResult() may simplify the code a little bit.
| List<Subchannel> list, @Nullable Status status, int startIndex, | ||
| ReadyPicker(List<Subchannel> list, int startIndex, | ||
| @Nullable RoundRobinLoadBalancer.StickinessState stickinessState) { | ||
| assert !list.isEmpty(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should be a checkArgument(). If this did happen in production and it was let pass this point, it would cause an out-of-bound exception at a later point.
| @Override | ||
| boolean isEquivalentTo(RoundRobinPicker picker) { | ||
| if (!(picker instanceof ReadyPicker)) { | ||
| return false; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is not covered.
- Use explicit check for non-empty list instead of assert - Change EmptyPicker.status to be non-nullable - Further test coverage improvement including explicit picker comparison tests
|
@zhangkun83 I've addressed your latest comments, PTAL. In particular there are now no new uncovered lines. Thanks! |
zhangkun83
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM except for a minor comment.
| private final Random random; | ||
|
|
||
| private ConnectivityState currentState; | ||
| private RoundRobinPicker currentPicker = new EmptyPicker(Status.OK); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use EMPTY_OK
|
Thanks @zhangkun83, have now addressed your remaining minor comment. |
|
@carl-mastrangelo do you want to take another look? |
carl-mastrangelo
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
Some style nits but i don't want to block this.
|
@njhill Merged, thanks! Sorry for the excessively long review process! Hopefully this hasn't scared you off; we do appreciate your contributions. This will be released in 1.16 in about 6 weeks. |
The "active" list and corresponding circular index used by
RoundRobinLoadBalancer's picker is currently refreshed via a call tohelper.updateBalancingState()after any change to a Subchannel's state or the resolved address groups, regardless of whether that change actually results in a change to the contents of the list.In fact it appears that the majority of Picker refreshes may be no-ops - for example any time a new EAG is discovered while there are already active Subchannels, its new Subchannel is added in
IDLEstate followed by an immediate Picker refresh which will be unnecessary because the list only containsREADYSubchannels. A similar pattern occurs during Subchannel removal/shutdown.These avoidable perturbations to the load distribution may be exacerbating the behaviour described in #4462.
This PR makes changes to ensure this doesn't happen, and includes:
subchannelsmap if and only if its state is notSHUTDOWN. This also facilitates a simplification to the stickiness picker logic where list containment no longer needs to be checked when (re)registering a stickiness value.EmptyPickerclass, since the picker parameters and logic are disjoint from the non-empty case