Skip to content

Commit f150476

Browse files
Fix three defects found in review of the weighted scoring
Allocated fraction was measured against the wrong total. op_host_capacity stores totals raw and overprovisioning is applied when they are read, so dividing by the stored total made the fraction reach 1 at the host's physical size. On a cluster overcommitted 10 times every host clamped to 1, killing both the allocation term and the dominant resource term - on exactly the clusters this algorithm is for. - apply the cluster ratio to the denominator - drop hosts missing a CPU or memory capacity row instead of scoring the missing resource as untouched, which made them rank first Utilisation thresholds could be bypassed. Held-back hosts were appended before the random spread was applied, so the spread could shuffle a busy host into the lead. With 1 healthy host and a spread of 3, two thirds of deployments picked an over-threshold host. - spread over healthy hosts only, before anything else is appended A host with no load samples was treated as idle. It was exempt from the thresholds and its dominant resource term fell back to allocation, so a host with broken stats outranked every measured host and collected the deployments. - rank hosts we cannot measure behind every host we can - when nothing can be measured, ranking falls back to allocation as before Signed-off-by: Brad House <bhouse@nexthop.ai>
1 parent 17fd56d commit f150476

2 files changed

Lines changed: 106 additions & 42 deletions

File tree

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

Lines changed: 95 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import java.util.ArrayList;
2020
import java.util.Collections;
21+
import java.util.Comparator;
2122
import java.util.Date;
2223
import java.util.HashMap;
2324
import java.util.List;
@@ -34,9 +35,12 @@
3435
import com.cloud.capacity.CapacityManager;
3536
import com.cloud.capacity.CapacityVO;
3637
import com.cloud.capacity.dao.CapacityDao;
38+
import com.cloud.dc.ClusterDetailsDao;
39+
import com.cloud.dc.ClusterDetailsVO;
3740
import com.cloud.host.Host;
3841
import com.cloud.host.HostScoringWeights;
3942
import com.cloud.utils.component.AdapterBase;
43+
import com.cloud.vm.VmDetailConstants;
4044
import com.cloud.vm.dao.VMInstanceDao;
4145

4246
/**
@@ -109,6 +113,9 @@ public class WeightedHostScorer extends AdapterBase implements Configurable {
109113
@Inject
110114
private CapacityDao capacityDao;
111115

116+
@Inject
117+
private ClusterDetailsDao clusterDetailsDao;
118+
112119
@Inject
113120
private VMInstanceDao vmInstanceDao;
114121

@@ -128,19 +135,53 @@ public List<Host> rank(long zoneId, Long podId, Long clusterId, List<? extends H
128135

129136
Map<Long, Double> scores = score(zoneId, podId, clusterId, hosts);
130137

131-
List<Host> scored = hosts.stream().filter(h -> scores.containsKey(h.getId())).collect(Collectors.toList());
132-
List<Host> unscored = hosts.stream().filter(h -> !scores.containsKey(h.getId())).collect(Collectors.toList());
133-
scored.sort((a, b) -> Double.compare(scores.get(a.getId()), scores.get(b.getId())));
138+
List<Host> unscored = new ArrayList<>();
139+
List<Host> measured = new ArrayList<>();
140+
List<Host> unmeasured = new ArrayList<>();
141+
for (Host host : hosts) {
142+
if (!scores.containsKey(host.getId())) {
143+
unscored.add(host);
144+
} else if (hostLoadTracker.getLoad(host.getId()).isUsable()) {
145+
measured.add(host);
146+
} else {
147+
unmeasured.add(host);
148+
}
149+
}
150+
151+
Comparator<Host> byScore = Comparator.comparingDouble(h -> scores.get(h.getId()));
152+
measured.sort(byScore);
153+
unmeasured.sort(byScore);
134154

135-
List<Host> admitted = applyUtilisationThresholds(clusterId, scored);
136-
applySelectionSpread(clusterId, admitted);
155+
List<Host> healthy = new ArrayList<>();
156+
List<Host> tooBusy = new ArrayList<>();
157+
partitionByUtilisation(clusterId, measured, healthy, tooBusy);
137158

138-
logger.debug("Weighted host ranking: {}", () -> admitted.stream()
159+
List<Host> result = new ArrayList<>();
160+
if (healthy.isEmpty() && unmeasured.isEmpty()) {
161+
logger.warn("Every candidate host is above its utilisation threshold, so the thresholds are being "
162+
+ "ignored for this deployment. The cluster is short of capacity.");
163+
result.addAll(tooBusy);
164+
applySelectionSpread(clusterId, result);
165+
} else {
166+
result.addAll(healthy);
167+
// spread only over hosts known to be healthy, before anything else is appended,
168+
// otherwise a busy or unmeasured host can be shuffled into the lead
169+
applySelectionSpread(clusterId, result);
170+
// a host we cannot measure is not assumed to be idle: it ranks behind every host we can
171+
result.addAll(unmeasured);
172+
result.addAll(tooBusy);
173+
}
174+
175+
if (!tooBusy.isEmpty()) {
176+
logger.debug("Holding back {} host(s) above their utilisation threshold: {}", tooBusy.size(), tooBusy);
177+
}
178+
logger.debug("Weighted host ranking: {}", () -> result.stream()
179+
.filter(h -> scores.containsKey(h.getId()))
139180
.map(h -> String.format("%s=%.4f", h.getName(), scores.get(h.getId())))
140181
.collect(Collectors.joining(", ")));
141182

142-
admitted.addAll(unscored);
143-
return admitted;
183+
result.addAll(unscored);
184+
return result;
144185
}
145186

146187
protected Map<Long, Double> score(long zoneId, Long podId, Long clusterId, List<? extends Host> hosts) {
@@ -165,27 +206,58 @@ protected Map<Long, Double> score(long zoneId, Long podId, Long clusterId, List<
165206
}
166207

167208
/**
168-
* Allocated CPU and memory as a fraction of what the host advertises after overprovisioning,
169-
* which is the same basis the existing allocators use.
209+
* Allocated CPU and memory as a fraction of what a host can hand out.
210+
*
211+
* op_host_capacity stores totals raw; overprovisioning is applied when they are read, so the
212+
* cluster's ratio has to be applied here too. Without it the fraction reaches 1 at the host's
213+
* physical size and every host on an overcommitted cluster clamps to 1, which is where this
214+
* algorithm is most needed.
215+
*
216+
* Only hosts with both a CPU and a memory row are returned. A host missing one would otherwise
217+
* score as if that resource were untouched, making it the most attractive host in the cluster.
170218
*/
171219
protected Map<Long, Double[]> allocatedFractions(List<CapacityVO> capacities) {
172220
Map<Long, Double[]> fractions = new HashMap<>();
221+
Map<Long, Integer> seen = new HashMap<>();
173222
for (CapacityVO capacity : capacities) {
174223
long total = capacity.getTotalCapacity();
175224
if (total <= 0) {
176225
continue;
177226
}
178-
double used = (double) (capacity.getUsedCapacity() + capacity.getReservedCapacity()) / total;
227+
boolean isCpu = capacity.getCapacityType() == Capacity.CAPACITY_TYPE_CPU;
228+
float overcommit = overcommitRatio(capacity.getClusterId(), isCpu);
229+
double allocatable = total * overcommit;
230+
double used = (double) (capacity.getUsedCapacity() + capacity.getReservedCapacity()) / allocatable;
231+
179232
Double[] entry = fractions.computeIfAbsent(capacity.getHostOrPoolId(), id -> new Double[] {0.0, 0.0});
180-
if (capacity.getCapacityType() == Capacity.CAPACITY_TYPE_CPU) {
181-
entry[0] = clamp(used);
182-
} else {
183-
entry[1] = clamp(used);
184-
}
233+
entry[isCpu ? 0 : 1] = clamp(used);
234+
seen.merge(capacity.getHostOrPoolId(), isCpu ? 1 : 2, Integer::sum);
185235
}
236+
fractions.keySet().removeIf(hostId -> seen.getOrDefault(hostId, 0) != 3);
186237
return fractions;
187238
}
188239

240+
/**
241+
* The cluster's overprovisioning factor, defaulting to none if it cannot be read.
242+
*/
243+
protected float overcommitRatio(Long clusterId, boolean forCpu) {
244+
if (clusterId == null) {
245+
return 1f;
246+
}
247+
String key = forCpu ? VmDetailConstants.CPU_OVER_COMMIT_RATIO : VmDetailConstants.MEMORY_OVER_COMMIT_RATIO;
248+
ClusterDetailsVO detail = clusterDetailsDao.findDetail(clusterId, key);
249+
if (detail == null || detail.getValue() == null) {
250+
return 1f;
251+
}
252+
try {
253+
float ratio = Float.parseFloat(detail.getValue());
254+
return ratio > 0 ? ratio : 1f;
255+
} catch (NumberFormatException e) {
256+
logger.warn("Cluster {} has an unreadable {} of [{}], treating it as 1.", clusterId, key, detail.getValue());
257+
return 1f;
258+
}
259+
}
260+
189261
/**
190262
* The blend. Every term is a fraction of the host's capacity for that resource so the weights
191263
* are directly comparable, and the dominant resource term is added on top of the weighted mean
@@ -238,35 +310,21 @@ protected double dominantResource(double cpuAllocated, double memoryAllocated, H
238310
}
239311

240312
/**
241-
* Holds back hosts that are measurably too busy, unless that would leave nothing to deploy on,
242-
* in which case ranking alone decides and the caller's capacity checks still apply.
313+
* Splits measurably busy hosts out from the rest. Only hosts with load samples can be held
314+
* back; a host that cannot be measured is dealt with by the caller.
243315
*/
244-
protected List<Host> applyUtilisationThresholds(Long clusterId, List<Host> ranked) {
316+
protected void partitionByUtilisation(Long clusterId, List<Host> measured, List<Host> healthy, List<Host> tooBusy) {
245317
double cpuThreshold = valueIn(CpuUtilisationThreshold, clusterId);
246318
double memoryThreshold = valueIn(MemoryUtilisationThreshold, clusterId);
247319

248-
List<Host> admitted = new ArrayList<>();
249-
List<Host> heldBack = new ArrayList<>();
250-
for (Host host : ranked) {
320+
for (Host host : measured) {
251321
HostLoad load = hostLoadTracker.getLoad(host.getId());
252-
if (load.isUsable()
253-
&& (load.getCpuUtilisation() > cpuThreshold || load.getMemoryUtilisation() > memoryThreshold)) {
254-
heldBack.add(host);
322+
if (load.getCpuUtilisation() > cpuThreshold || load.getMemoryUtilisation() > memoryThreshold) {
323+
tooBusy.add(host);
255324
} else {
256-
admitted.add(host);
325+
healthy.add(host);
257326
}
258327
}
259-
260-
if (admitted.isEmpty()) {
261-
logger.warn("Every candidate host is above its utilisation threshold, so the thresholds are being "
262-
+ "ignored for this deployment. The cluster is short of capacity.");
263-
return heldBack;
264-
}
265-
if (!heldBack.isEmpty()) {
266-
logger.debug("Holding back {} host(s) above their utilisation threshold: {}", heldBack.size(), heldBack);
267-
admitted.addAll(heldBack);
268-
}
269-
return admitted;
270328
}
271329

272330
/**

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,13 @@ public void testBusyHostIsHeldBackByThreshold() {
130130
Mockito.when(hostLoadTracker.getLoad(quiet.getId())).thenReturn(new HostLoad(0.10, 0.10, 10));
131131
Mockito.when(hostLoadTracker.getLoad(busy.getId())).thenReturn(new HostLoad(0.99, 0.10, 10));
132132

133-
List<Host> result = scorer.applyUtilisationThresholds(null, new ArrayList<>(List.of(busy, quiet)));
133+
List<Host> healthy = new ArrayList<>();
134+
List<Host> tooBusy = new ArrayList<>();
135+
scorer.partitionByUtilisation(null, new ArrayList<>(List.of(quiet, busy)), healthy, tooBusy);
134136

135-
assertSame("host over the CPU threshold must fall behind", quiet, result.get(0));
136-
assertSame(busy, result.get(1));
137+
assertEquals(1, healthy.size());
138+
assertSame(quiet, healthy.get(0));
139+
assertSame("host over the CPU threshold must be held back", busy, tooBusy.get(0));
137140
}
138141

139142
@Test
@@ -142,9 +145,12 @@ public void testThresholdIsIgnoredWhenEveryHostIsBusy() {
142145
Host b = host("b");
143146
Mockito.when(hostLoadTracker.getLoad(Mockito.anyLong())).thenReturn(new HostLoad(0.99, 0.99, 10));
144147

145-
List<Host> result = scorer.applyUtilisationThresholds(null, new ArrayList<>(List.of(a, b)));
148+
List<Host> healthy = new ArrayList<>();
149+
List<Host> tooBusy = new ArrayList<>();
150+
scorer.partitionByUtilisation(null, new ArrayList<>(List.of(a, b)), healthy, tooBusy);
146151

147-
assertEquals("deployment must still be possible when the whole cluster is busy", 2, result.size());
152+
assertEquals("both hosts are over threshold", 2, tooBusy.size());
153+
assertTrue(healthy.isEmpty());
148154
}
149155

150156
@Test

0 commit comments

Comments
 (0)