|
| 1 | +// Licensed to the Software Freedom Conservancy (SFC) under one |
| 2 | +// or more contributor license agreements. See the NOTICE file |
| 3 | +// distributed with this work for additional information |
| 4 | +// regarding copyright ownership. The SFC licenses this file |
| 5 | +// to you under the Apache License, Version 2.0 (the |
| 6 | +// "License"); you may not use this file except in compliance |
| 7 | +// with the License. You may obtain a copy of the License at |
| 8 | +// |
| 9 | +// http://www.apache.org/licenses/LICENSE-2.0 |
| 10 | +// |
| 11 | +// Unless required by applicable law or agreed to in writing, |
| 12 | +// software distributed under the License is distributed on an |
| 13 | +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| 14 | +// KIND, either express or implied. See the License for the |
| 15 | +// specific language governing permissions and limitations |
| 16 | +// under the License. |
| 17 | + |
| 18 | +package org.openqa.selenium.grid.distributor.selector; |
| 19 | + |
| 20 | +import com.google.common.annotations.VisibleForTesting; |
| 21 | +import org.openqa.selenium.Capabilities; |
| 22 | +import org.openqa.selenium.grid.distributor.model.Host; |
| 23 | + |
| 24 | +import java.util.Comparator; |
| 25 | +import java.util.HashMap; |
| 26 | +import java.util.HashSet; |
| 27 | +import java.util.List; |
| 28 | +import java.util.Map; |
| 29 | +import java.util.Optional; |
| 30 | +import java.util.Set; |
| 31 | +import java.util.logging.Logger; |
| 32 | +import java.util.stream.Collectors; |
| 33 | +import java.util.stream.Stream; |
| 34 | + |
| 35 | +import static org.openqa.selenium.grid.distributor.model.Host.Status.UP; |
| 36 | + |
| 37 | +public class DefaultHostSelector implements HostSelector { |
| 38 | + |
| 39 | + private static final Logger LOG = Logger.getLogger("Selenium Host Selector"); |
| 40 | + |
| 41 | + @Override |
| 42 | + public Optional<Host> selectHost(Capabilities capabilities, Set<Host> hosts) { |
| 43 | + Optional<Host> selected; |
| 44 | + Stream<Host> firstRound = hosts.stream() |
| 45 | + .filter(host -> host.getHostStatus() == UP) |
| 46 | + // Find a host that supports this kind of thing |
| 47 | + .filter(host -> host.hasCapacity(capabilities)); |
| 48 | + |
| 49 | + //of the hosts that survived the first round, separate into buckets and prioritize by browser "rarity" |
| 50 | + Stream<Host> prioritizedHosts = getPrioritizedHostStream(firstRound, capabilities); |
| 51 | + |
| 52 | + //Take the further-filtered Stream and prioritize by load, then by session age |
| 53 | + selected = prioritizedHosts |
| 54 | + .min( |
| 55 | + // Now sort by node which has the lowest load (natural ordering) |
| 56 | + Comparator.comparingDouble(Host::getLoad) |
| 57 | + // Then last session created (oldest first), so natural ordering again |
| 58 | + .thenComparingLong(Host::getLastSessionCreated) |
| 59 | + // And use the host id as a tie-breaker. |
| 60 | + .thenComparing(Host::getId)); |
| 61 | + return selected; |
| 62 | + } |
| 63 | + |
| 64 | + /** |
| 65 | + * Takes a Stream of Hosts, along with the Capabilities of the current request, and prioritizes the |
| 66 | + * request by removing Hosts that offer Capabilities that are more rare. e.g. if there are only a |
| 67 | + * couple Edge nodes, but a lot of Chrome nodes, the Edge nodes should be removed from |
| 68 | + * consideration when Chrome is requested. This does not currently take the amount of load on the |
| 69 | + * server into consideration--it only checks for availability, not how much availability |
| 70 | + * @param hostStream Stream of hosts attached to the Distributor (assume it's filtered for only those that offer these Capabilities) |
| 71 | + * @param capabilities Passing in the whole Capabilities object will allow us to prioritize more than just browser |
| 72 | + * @return Stream of distinct Hosts with the more rare Capabilities removed |
| 73 | + */ |
| 74 | + @VisibleForTesting |
| 75 | + Stream<Host> getPrioritizedHostStream(Stream<Host> hostStream, Capabilities capabilities) { |
| 76 | + //TODO for the moment, we're not going to operate on the Stream that was passed in--we need to |
| 77 | + // alter and futz with the contents, so the stream isn't the right place to operate. This |
| 78 | + // will likely be optimized back into the algo, but not yet |
| 79 | + Set<Host> filteredHostSet = hostStream.collect(Collectors.toSet()); |
| 80 | + |
| 81 | + //A "bucket" is a list of hosts that can use a particular browser. The "edge" bucket is the |
| 82 | + // complete list of Hosts that support "edge". By separating Hosts into buckets, we will |
| 83 | + // know which browsers have fewer nodes available for the browsers we're not interested in, and |
| 84 | + // can prioritize based on the browsers that have more availability |
| 85 | + Map<String, Set<Host>> hostBuckets = sortHostsToBucketsByBrowser(filteredHostSet); |
| 86 | + |
| 87 | + //First, check to see if all buckets are the same size. If they are, just send back the full list of hosts |
| 88 | + // (i.e. the hosts are all "balanced" with regard to browser priority) |
| 89 | + if (allBucketsSameSize(hostBuckets)) { |
| 90 | + return hostBuckets.values().stream().distinct().flatMap(Set::stream); |
| 91 | + } |
| 92 | + |
| 93 | + //Then, starting with the smallest bucket that isn't the current browser being prioritized, |
| 94 | + // remove all hosts in that bucket from consideration, then rebuild the buckets. Then do the |
| 95 | + // "same size" check again, and keep doing this until either a) there is only one bucket, or b) |
| 96 | + // all buckets are the same size |
| 97 | + |
| 98 | + //Note: there should never be a case where a bucket will have *more* nodes available for the |
| 99 | + // given browser than the one being requested. The first filter in this check looks for |
| 100 | + // "equal", not "equal-or-greater" as a result of this assumption |
| 101 | + |
| 102 | + //There might be unforeseen cases that challenge this assumption. TODO Create unit tests to prove it |
| 103 | + |
| 104 | + //TODO a List of Map.Entry is silly. whatever this structure needs to be needs to be returned by |
| 105 | + // the sortHostsToBucketsByBrowser method in a way that we don't have to sort it separately like this |
| 106 | + final List<Map.Entry<String, Set<Host>>> sorted = hostBuckets.entrySet().stream().sorted( |
| 107 | + Comparator.comparingInt(v -> v.getValue().size()) |
| 108 | + ).collect(Collectors.toList()); |
| 109 | + |
| 110 | + // Until the buckets are the same size, keep removing hosts that have more "rare" browser capabilities |
| 111 | + Map<String, Set<Host>> newHostBuckets; |
| 112 | + for (Map.Entry<String, Set<Host>> entry : sorted) { |
| 113 | + //Don't examine the bucket containing the browser in question--we're prioritizing the other browsers |
| 114 | + //TODO This shouldn't be necessary, because if the list is sorted by size, this won't be possible until |
| 115 | + // they're all the same size. Create a unit test to prove it |
| 116 | + if (entry.getKey().equals(capabilities.getBrowserName())) { |
| 117 | + continue; |
| 118 | + } |
| 119 | + |
| 120 | + //Remove all hosts from this bucket from the full set of eligible hosts |
| 121 | + final Set<Host> filteredHosts = filteredHostSet.stream().filter(host -> !entry.getValue().contains(host)).collect(Collectors.toSet()); |
| 122 | + |
| 123 | + //Rebuild the buckets by browser |
| 124 | + newHostBuckets = sortHostsToBucketsByBrowser(filteredHosts); |
| 125 | + |
| 126 | + //Check the bucket sizes--if they're the same, then we're done |
| 127 | + if (allBucketsSameSize(newHostBuckets)) { |
| 128 | + LOG.fine("Hosts have been balanced according to browser priority"); |
| 129 | + return newHostBuckets.values().stream().distinct().flatMap(Set::stream); |
| 130 | + } |
| 131 | + } |
| 132 | + |
| 133 | + return hostBuckets.values().stream().distinct().flatMap(Set::stream); |
| 134 | + } |
| 135 | + |
| 136 | + Map<String, Set<Host>> sortHostsToBucketsByBrowser(Set<Host> hostSet) { |
| 137 | + //Make a hash of browserType -> list of hosts that support it |
| 138 | + Map<String, Set<Host>> hostBuckets = new HashMap<>(); |
| 139 | + hostSet.forEach(host -> host.asSummary().getStereotypes().forEach((k, v) -> { |
| 140 | + if (!hostBuckets.containsKey(k.getBrowserName())) { |
| 141 | + Set<Host> newSet = new HashSet<>(); |
| 142 | + newSet.add(host); |
| 143 | + hostBuckets.put(k.getBrowserName(), newSet); |
| 144 | + } |
| 145 | + hostBuckets.get(k.getBrowserName()).add(host); |
| 146 | + })); |
| 147 | + return hostBuckets; |
| 148 | + } |
| 149 | + |
| 150 | + @VisibleForTesting |
| 151 | + boolean allBucketsSameSize(Map<String, Set<Host>> hostBuckets) { |
| 152 | + Set<Integer> intSet = new HashSet<>(); |
| 153 | + hostBuckets.values().forEach(bucket -> intSet.add(bucket.size())); |
| 154 | + return intSet.size() == 1; |
| 155 | + } |
| 156 | +} |
0 commit comments