Skip to content

Commit 7bcf58f

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 8a3c8ae commit 7bcf58f

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;
@@ -133,16 +134,17 @@ public class WeightedHostScorer extends AdapterBase implements Configurable {
133134
* order at the end of the list rather than being dropped.
134135
*/
135136
public List<Host> rank(long zoneId, Long podId, Long clusterId, List<? extends Host> hosts) {
136-
if (hosts == null || hosts.size() <= 1) {
137-
return hosts == null ? new ArrayList<>() : new ArrayList<>(hosts);
137+
List<Host> candidates = distinctHosts(hosts);
138+
if (candidates.size() <= 1) {
139+
return candidates;
138140
}
139141

140-
Map<Long, Double> scores = score(zoneId, podId, clusterId, hosts);
142+
Map<Long, Double> scores = score(zoneId, podId, clusterId, candidates);
141143

142144
List<Host> unscored = new ArrayList<>();
143145
List<Host> measured = new ArrayList<>();
144146
List<Host> unmeasured = new ArrayList<>();
145-
for (Host host : hosts) {
147+
for (Host host : candidates) {
146148
if (!scores.containsKey(host.getId())) {
147149
unscored.add(host);
148150
} else if (hostLoadTracker.getLoad(host.getId()).isUsable()) {
@@ -188,6 +190,24 @@ public List<Host> rank(long zoneId, Long podId, Long clusterId, List<? extends H
188190
return result;
189191
}
190192

193+
/**
194+
* The candidate list can hold the same host twice. listAllUpAndEnabledNonHAHosts joins host_tags
195+
* without collapsing the rows, so a host carrying two tags arrives once per tag, and that path is
196+
* taken whenever a VM has no host tag to filter on - system VMs, most commonly. Ranking is a set
197+
* operation: a repeat is harmless to the score, which is keyed by host, but it costs a slot in
198+
* the selection spread and would bias the shuffle towards whichever host happens to be repeated.
199+
*/
200+
protected List<Host> distinctHosts(List<? extends Host> hosts) {
201+
if (hosts == null) {
202+
return new ArrayList<>();
203+
}
204+
Map<Long, Host> distinct = new LinkedHashMap<>();
205+
for (Host host : hosts) {
206+
distinct.putIfAbsent(host.getId(), host);
207+
}
208+
return new ArrayList<>(distinct.values());
209+
}
210+
191211
protected Map<Long, Double> score(long zoneId, Long podId, Long clusterId, List<? extends Host> hosts) {
192212
List<CapacityVO> capacities = capacityDao.listHostCapacityByCapacityTypes(zoneId, clusterId,
193213
List.of(Capacity.CAPACITY_TYPE_CPU, Capacity.CAPACITY_TYPE_MEMORY));

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
@@ -133,6 +133,22 @@ public void testAllocationIsMeasuredAgainstTheOvercommittedTotal() {
133133
scores.get(heavy.getId()) > scores.get(light.getId()));
134134
}
135135

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

0 commit comments

Comments
 (0)