Skip to content

Commit e6b6325

Browse files
Rank each candidate host once, however many tags it carries
listAllUpAndEnabledNonHAHosts joins host_tags to filter out hosts whose tags are rules, and the join carries no DISTINCT, so a host comes back once per non-rule tag it holds. That path is taken whenever a VM has no host tag to filter the candidate list by, which in practice means system VMs: a virtual router deploying onto a twelve host cluster where three hosts carry a second tag is handed fifteen candidates. An allocator that walks the list and takes the first host that fits is indifferent to a repeat, which is why this has gone unnoticed. The weighted ranking is not. Scores are keyed by host, so a repeat changes no score, but the selection spread shuffles the leading few entries and the caller takes whichever ends up first - so a host holding two of the three slots draws two thirds of the deployments rather than one third. Collapse the repeats in the DAO, where the join creates them and every caller benefits, and again in the scorer, so ranking cannot be skewed by whatever a future caller hands it. Signed-off-by: Brad House <bhouse@nexthop.ai>
1 parent 01b6871 commit e6b6325

4 files changed

Lines changed: 76 additions & 5 deletions

File tree

‎engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import java.util.Date;
2525
import java.util.HashMap;
2626
import java.util.HashSet;
27+
import java.util.LinkedHashMap;
2728
import java.util.List;
2829
import java.util.Map;
2930
import java.util.Objects;
@@ -935,7 +936,14 @@ public List<HostVO> listAllUpAndEnabledNonHAHosts(Type type, Long clusterId, Lon
935936
sc.setParameters("status", Status.Up);
936937
sc.setParameters("resourceState", ResourceState.Enabled);
937938

938-
return listBy(sc);
939+
// The tag join carries no DISTINCT, so a host comes back once per non-rule tag it holds.
940+
// Callers read this as a set of candidate hosts, so collapse the repeats here rather than
941+
// leave each of them to cope with the same host arriving more than once.
942+
Map<Long, HostVO> distinctHosts = new LinkedHashMap<>();
943+
for (HostVO host : listBy(sc)) {
944+
distinctHosts.putIfAbsent(host.getId(), host);
945+
}
946+
return new ArrayList<>(distinctHosts.values());
939947
}
940948

941949
@Override

‎engine/schema/src/test/java/com/cloud/host/dao/HostDaoImplTest.java‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939

4040
import com.cloud.cpu.CPU;
4141
import com.cloud.host.Host;
42+
import com.cloud.host.HostTagVO;
4243
import com.cloud.host.HostVO;
4344
import com.cloud.host.Status;
4445
import com.cloud.hypervisor.Hypervisor;
@@ -60,6 +61,32 @@ public class HostDaoImplTest {
6061
@Mock
6162
private SearchCriteria<HostVO> mockSearchCriteria;
6263

64+
@Test
65+
public void testListAllUpAndEnabledNonHAHostsCollapsesRepeatedHosts() {
66+
// the host_tags join carries no DISTINCT, so a host comes back once per non-rule tag it
67+
// holds. Callers read the result as a set of candidate hosts.
68+
SearchBuilder<HostTagVO> tagSearchBuilder = mock(SearchBuilder.class);
69+
when(tagSearchBuilder.entity()).thenReturn(mock(HostTagVO.class));
70+
HostTagsDao hostTagsDao = mock(HostTagsDao.class);
71+
when(hostTagsDao.createSearchBuilder()).thenReturn(tagSearchBuilder);
72+
hostDao._hostTagsDao = hostTagsDao;
73+
74+
when(mockSearchBuilder.entity()).thenReturn(mock(HostVO.class));
75+
when(mockSearchBuilder.create()).thenReturn(mockSearchCriteria);
76+
doReturn(mockSearchBuilder).when(hostDao).createSearchBuilder();
77+
78+
HostVO twoTags = mock(HostVO.class);
79+
when(twoTags.getId()).thenReturn(1L);
80+
HostVO oneTag = mock(HostVO.class);
81+
when(oneTag.getId()).thenReturn(2L);
82+
doReturn(Arrays.asList(twoTags, twoTags, oneTag)).when(hostDao).listBy(any(SearchCriteria.class));
83+
84+
List<HostVO> hosts = hostDao.listAllUpAndEnabledNonHAHosts(Host.Type.Routing, 3L, 2L, 1L, null);
85+
86+
assertEquals(2, hosts.size());
87+
assertEquals(Arrays.asList(1L, 2L), hosts.stream().map(HostVO::getId).collect(Collectors.toList()));
88+
}
89+
6390
@Test
6491
public void testCountUpAndEnabledHostsInZone() {
6592
long testZoneId = 100L;

‎server/src/main/java/com/cloud/agent/manager/allocator/impl/WeightedHostScorer.java‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import java.util.Comparator;
2222
import java.util.Date;
2323
import java.util.HashMap;
24+
import java.util.LinkedHashMap;
2425
import java.util.List;
2526
import java.util.Map;
2627
import java.util.Random;
@@ -140,17 +141,18 @@ public class WeightedHostScorer extends AdapterBase implements Configurable {
140141
* order at the end of the list rather than being dropped.
141142
*/
142143
public List<Host> rank(long zoneId, Long podId, Long clusterId, List<? extends Host> hosts) {
143-
if (hosts == null || hosts.size() <= 1) {
144-
return hosts == null ? new ArrayList<>() : new ArrayList<>(hosts);
144+
List<Host> candidates = distinctHosts(hosts);
145+
if (candidates.size() <= 1) {
146+
return candidates;
145147
}
146148

147149
Map<Long, Ternary<Long, Long, Long>> vmCounts = countVmsByHost(zoneId, podId, clusterId);
148-
Map<Long, Double> scores = score(zoneId, clusterId, hosts, vmCounts);
150+
Map<Long, Double> scores = score(zoneId, clusterId, candidates, vmCounts);
149151

150152
List<Host> unscored = new ArrayList<>();
151153
List<Host> measured = new ArrayList<>();
152154
List<Host> unmeasured = new ArrayList<>();
153-
for (Host host : hosts) {
155+
for (Host host : candidates) {
154156
if (!scores.containsKey(host.getId())) {
155157
unscored.add(host);
156158
} else if (hostLoadTracker.getLoad(host.getId()).isUsable()) {
@@ -214,6 +216,24 @@ public List<Host> rank(long zoneId, Long podId, Long clusterId, List<? extends H
214216
return result;
215217
}
216218

219+
/**
220+
* The candidate list can hold the same host twice. listAllUpAndEnabledNonHAHosts joins host_tags
221+
* without collapsing the rows, so a host carrying two tags arrives once per tag, and that path is
222+
* taken whenever a VM has no host tag to filter on - system VMs, most commonly. Ranking is a set
223+
* operation: a repeat is harmless to the score, which is keyed by host, but it costs a slot in
224+
* the selection spread and would bias the shuffle towards whichever host happens to be repeated.
225+
*/
226+
protected List<Host> distinctHosts(List<? extends Host> hosts) {
227+
if (hosts == null) {
228+
return new ArrayList<>();
229+
}
230+
Map<Long, Host> distinct = new LinkedHashMap<>();
231+
for (Host host : hosts) {
232+
distinct.putIfAbsent(host.getId(), host);
233+
}
234+
return new ArrayList<>(distinct.values());
235+
}
236+
217237
protected Map<Long, Ternary<Long, Long, Long>> countVmsByHost(long zoneId, Long podId, Long clusterId) {
218238
return vmInstanceDao.countVmsByHost(zoneId, podId, clusterId,
219239
new Date(System.currentTimeMillis() - RecentStartWindow.value() * 1000L));

‎server/src/test/java/com/cloud/agent/manager/allocator/impl/WeightedHostScorerRankTest.java‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,22 @@ public void testAllocationIsMeasuredAgainstTheOvercommittedTotal() {
140140
scores.get(heavy.getId()) > scores.get(light.getId()));
141141
}
142142

143+
@Test
144+
public void testARepeatedHostIsRankedOnce() {
145+
// listAllUpAndEnabledNonHAHosts returns a host once per tag it carries, so the same host
146+
// reaches the allocator more than once whenever a VM has no tag to filter the list by.
147+
// A repeat would take an extra slot in the selection spread and bias the shuffle to it.
148+
Host light = host(1L, "light", 0.10, 0.10, new HostLoad(0.10, 0.10, 10), 10);
149+
Host middle = host(2L, "middle", 0.40, 0.40, new HostLoad(0.40, 0.40, 10), 40);
150+
Host heavy = host(3L, "heavy", 0.70, 0.70, new HostLoad(0.70, 0.60, 10), 70);
151+
152+
List<String> ranked = rankedNames(Arrays.asList(middle, light, middle, heavy, light));
153+
154+
assertEquals("each host must be offered exactly once", 3, ranked.size());
155+
assertEquals("no host may appear twice", new HashSet<>(ranked).size(), ranked.size());
156+
assertTrue("no host may be dropped", ranked.containsAll(Arrays.asList("light", "middle", "heavy")));
157+
}
158+
143159
@Test
144160
public void testBusyHostRanksBehindQuietOneAtEqualAllocation() {
145161
Host quiet = host(1L, "quiet", 0.30, 0.30, new HostLoad(0.05, 0.05, 10), 30);

0 commit comments

Comments
 (0)