Skip to content

Commit e868c8a

Browse files
Hold back hosts with a backlog of VMs still starting
The symptom this addresses is a burst of deployments all landing on one host and then sitting in Starting for minutes instead of seconds, while that host works through a queue of starts its storage and agent command path cannot absorb. Nothing in the algorithm saw that. Capacity is not charged until a VM reaches Starting, which is after the allocator has returned, and the utilisation average is a sample interval and a half life behind, so a burst is over before either notices. The recent-start term is the closest thing to a guard and it cannot do the job: it counts VMs whose state changed within a window, so a VM stuck in Starting for ten minutes stops counting after five - the signal decays fastest on the hosts that are worst off - and its contribution is capped at 0.111 of the score, so a host with 200 VMs stuck in Starting ranks the same as one with 50. Count the VMs actually in Starting instead, and use it as a gate rather than a weight. It does not expire while the condition persists, it is read fresh on every ranking rather than sampled, and a gate cannot be outvoted by the other terms. It costs nothing: the per-host VM count query already filters on vm.state, so it is one more aggregate in a query that was already being made. Held-back hosts are still never dropped, only moved down the list, so this cannot turn a deployment that would have succeeded into an InsufficientServerCapacity failure. Within the held-back group the order is by starting count, so if every host is backed up the least backed up one is tried first. Also fix a hole in the selection spread. It was applied to the healthy list only, so whenever no host was eligible that list was empty, the shuffle was a no-op, and the held-back hosts were handed out in strict score order - concurrent deployments agreeing on one host in exactly the conditions that caused the pile-up. The spread now applies to whichever tier leads, which also removes the special-cased branch it was hiding in. The "short of capacity" warning stays narrow: hosts merely being unmeasured, as they all are on a freshly started management server, is not a capacity problem and must not warn on every deployment. Signed-off-by: Brad House <bhouse@nexthop.ai>
1 parent b11473f commit e868c8a

5 files changed

Lines changed: 226 additions & 30 deletions

File tree

‎PendingReleaseNotes‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,15 @@ example.ver.1 > example.ver.2:
6868
hosts that are measurably too busy, and chooses at random from among the best scoring hosts
6969
so that simultaneous deployments do not all pick the same one.
7070

71+
It also holds back hosts that already have host.weighted.starting.vms.threshold VMs sitting
72+
in the Starting state, so that a burst of deployments does not queue up behind itself on one
73+
host. This is the only signal that reacts within a single deployment: capacity is not charged
74+
until a VM reaches Starting and a utilisation average is a sample interval behind, so neither
75+
notices a burst until it is over. Set the threshold to 0 to disable it.
76+
77+
Held-back hosts are never excluded, only moved to the back of the list, so no threshold can
78+
turn a deployment that would have succeeded into an InsufficientServerCapacity failure.
79+
7180
Tuned with the host.weighted.* settings, most of which are cluster scoped. Measured
7281
utilisation comes from a moving average configured with host.load.sample.interval and
7382
host.load.half.life. Until a management server has collected samples, ranking falls back to

‎engine/schema/src/main/java/com/cloud/vm/dao/VMInstanceDao.java‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626
import com.cloud.hypervisor.Hypervisor;
2727
import com.cloud.utils.Pair;
28+
import com.cloud.utils.Ternary;
2829
import com.cloud.utils.db.GenericDao;
2930
import com.cloud.utils.fsm.StateDao;
3031
import com.cloud.vm.VMInstanceVO;
@@ -138,10 +139,10 @@ public interface VMInstanceDao extends GenericDao<VMInstanceVO, Long>, StateDao<
138139
* cut-off for the second count: VMs whose state last changed after this. Approximates
139140
* the VMs still working through their startup load, which neither allocation nor a
140141
* utilisation average has caught up with yet.
141-
* @return host id to {total VMs, VMs that changed state recently}. Every host in scope appears,
142-
* including hosts with no VMs.
142+
* @return host id to {total VMs, VMs that changed state recently, VMs still in Starting}. Every
143+
* host in scope appears, including hosts with no VMs.
143144
*/
144-
Map<Long, Pair<Long, Long>> countVmsByHost(long dcId, Long podId, Long clusterId, Date changedStateAfter);
145+
Map<Long, Ternary<Long, Long, Long>> countVmsByHost(long dcId, Long podId, Long clusterId, Date changedStateAfter);
145146

146147
Long countRunningAndStartingByAccount(long accountId);
147148

‎engine/schema/src/main/java/com/cloud/vm/dao/VMInstanceDaoImpl.java‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
import com.cloud.tags.dao.ResourceTagDao;
4646
import com.cloud.utils.DateUtil;
4747
import com.cloud.utils.Pair;
48+
import com.cloud.utils.Ternary;
4849
import com.cloud.utils.db.Attribute;
4950
import com.cloud.utils.db.DB;
5051
import com.cloud.utils.db.Filter;
@@ -158,7 +159,13 @@ public class VMInstanceDaoImpl extends GenericDaoBase<VMInstanceVO, Long> implem
158159
// %s is the "changed state recently" test, or a constant 0 when no cut-off is given. It is not
159160
// a bound parameter because there is no timestamp that reliably means "never" - update_time is
160161
// a TIMESTAMP column, so anything past 2038 is out of range.
161-
private static final String COUNT_VMS_BY_HOST = "SELECT host.id, COUNT(vm.id), SUM(IF(%s, 1, 0)) " +
162+
//
163+
// The third count is VMs still in Starting. Unlike the recent-state-change count it does not
164+
// expire on a clock: a VM that has been starting for ten minutes still counts, which is the
165+
// case that matters, since a host that is struggling is exactly the one whose VMs take longest
166+
// to leave Starting.
167+
private static final String COUNT_VMS_BY_HOST = "SELECT host.id, COUNT(vm.id), SUM(IF(%s, 1, 0)), " +
168+
"SUM(IF(vm.state = 'Starting', 1, 0)) " +
162169
"FROM `cloud`.`host` host LEFT JOIN `cloud`.`vm_instance` vm " +
163170
"ON vm.host_id = host.id AND vm.state IN ('Running', 'Starting', 'Stopping', 'Migrating') " +
164171
"AND vm.removed IS NULL WHERE host.type = 'Routing' AND host.removed IS NULL AND host.data_center_id = ? ";
@@ -807,9 +814,9 @@ public Pair<List<Long>, Map<Long, Double>> listPodIdsInZoneByVmCount(long dataCe
807814

808815

809816
@Override
810-
public Map<Long, Pair<Long, Long>> countVmsByHost(long dcId, Long podId, Long clusterId, Date changedStateAfter) {
817+
public Map<Long, Ternary<Long, Long, Long>> countVmsByHost(long dcId, Long podId, Long clusterId, Date changedStateAfter) {
811818
TransactionLegacy txn = TransactionLegacy.currentTxn();
812-
Map<Long, Pair<Long, Long>> result = new HashMap<>();
819+
Map<Long, Ternary<Long, Long, Long>> result = new HashMap<>();
813820
String sql = String.format(COUNT_VMS_BY_HOST, changedStateAfter != null ? "vm.update_time > ?" : "0");
814821
if (podId != null) {
815822
sql = sql + " AND host.pod_id = ? ";
@@ -833,7 +840,8 @@ public Map<Long, Pair<Long, Long>> countVmsByHost(long dcId, Long podId, Long cl
833840
}
834841
ResultSet rs = pstmt.executeQuery();
835842
while (rs.next()) {
836-
result.put(rs.getLong(1), new Pair<>(rs.getLong(2), rs.getLong(3)));
843+
// SUM over no rows is NULL, which getLong reports as 0 - correct for an empty host
844+
result.put(rs.getLong(1), new Ternary<>(rs.getLong(2), rs.getLong(3), rs.getLong(4)));
837845
}
838846
return result;
839847
} catch (SQLException e) {

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

Lines changed: 94 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
import com.cloud.dc.ClusterDetailsVO;
4040
import com.cloud.host.Host;
4141
import com.cloud.host.HostScoringWeights;
42-
import com.cloud.utils.Pair;
42+
import com.cloud.utils.Ternary;
4343
import com.cloud.utils.component.AdapterBase;
4444
import com.cloud.vm.VmDetailConstants;
4545
import com.cloud.vm.dao.VMInstanceDao;
@@ -111,7 +111,15 @@ public class WeightedHostScorer extends AdapterBase implements Configurable {
111111
+ "of them is accounted for. 1 restores strict ordering.",
112112
true, ConfigKey.Scope.Cluster);
113113

114-
private static final Pair<Long, Long> NO_VMS = new Pair<>(0L, 0L);
114+
public static final ConfigKey<Integer> StartingVmsThreshold = new ConfigKey<>(ConfigKey.CATEGORY_ADVANCED,
115+
Integer.class, "host.weighted.starting.vms.threshold", "10",
116+
"Hosts with at least this many VMs still in the Starting state are held back from new VMs, so "
117+
+ "that a burst of deployments does not queue up behind itself on one host. Unlike the "
118+
+ "utilisation thresholds this reacts within a single deployment rather than waiting for a "
119+
+ "load sample. Ignored if it would leave nowhere to deploy. Set to 0 to disable.",
120+
true, ConfigKey.Scope.Cluster);
121+
122+
private static final Ternary<Long, Long, Long> NO_VMS = new Ternary<>(0L, 0L, 0L);
115123

116124
@Inject
117125
private CapacityDao capacityDao;
@@ -136,7 +144,8 @@ public List<Host> rank(long zoneId, Long podId, Long clusterId, List<? extends H
136144
return hosts == null ? new ArrayList<>() : new ArrayList<>(hosts);
137145
}
138146

139-
Map<Long, Double> scores = score(zoneId, podId, clusterId, hosts);
147+
Map<Long, Ternary<Long, Long, Long>> vmCounts = countVmsByHost(zoneId, podId, clusterId);
148+
Map<Long, Double> scores = score(zoneId, clusterId, hosts, vmCounts);
140149

141150
List<Host> unscored = new ArrayList<>();
142151
List<Host> measured = new ArrayList<>();
@@ -159,25 +168,43 @@ public List<Host> rank(long zoneId, Long podId, Long clusterId, List<? extends H
159168
List<Host> tooBusy = new ArrayList<>();
160169
partitionByUtilisation(clusterId, measured, healthy, tooBusy);
161170

171+
// held back for having too many VMs still starting. Taken out of healthy, so a host that is
172+
// fine on resources but already working through a queue of starts stops attracting more.
173+
List<Host> backedUp = new ArrayList<>();
174+
partitionByStartingVms(clusterId, healthy, vmCounts, backedUp);
175+
176+
// Tiers, best first. Nothing is ever dropped: a tier is only reached when every better tier
177+
// is empty, so holding a host back can never turn a deployment that would have succeeded
178+
// into an InsufficientServerCapacity failure.
179+
//
180+
// healthy - no known problem
181+
// backedUp - fine on resources, momentarily queueing starts; clears on its own
182+
// unmeasured - unknown, and not assumed to be idle
183+
// tooBusy - measurably over a resource threshold
184+
List<List<Host>> tiers = List.of(healthy, backedUp, unmeasured, tooBusy);
162185
List<Host> result = new ArrayList<>();
163-
if (healthy.isEmpty() && unmeasured.isEmpty()) {
164-
logger.warn("Every candidate host is above its utilisation threshold, so the thresholds are being "
165-
+ "ignored for this deployment. The cluster is short of capacity.");
166-
result.addAll(tooBusy);
167-
applySelectionSpread(clusterId, result);
168-
} else {
169-
result.addAll(healthy);
170-
// spread only over hosts known to be healthy, before anything else is appended,
171-
// otherwise a busy or unmeasured host can be shuffled into the lead
172-
applySelectionSpread(clusterId, result);
173-
// a host we cannot measure is not assumed to be idle: it ranks behind every host we can
174-
result.addAll(unmeasured);
175-
result.addAll(tooBusy);
186+
for (List<Host> tier : tiers) {
187+
// the spread belongs on whichever tier deployments will actually draw from, which is
188+
// the first non-empty one. Applying it only to healthy left concurrent deployments in
189+
// strict score order - and so agreeing on one host - whenever healthy was empty.
190+
if (result.isEmpty()) {
191+
applySelectionSpread(clusterId, tier);
192+
}
193+
result.addAll(tier);
176194
}
177195

196+
// only when a held-back host actually has to be used. Hosts merely being unmeasured, as
197+
// they all are on a freshly started management server, is not a capacity problem.
198+
if (healthy.isEmpty() && unmeasured.isEmpty() && !(backedUp.isEmpty() && tooBusy.isEmpty())) {
199+
logger.warn("No candidate host is within its utilisation thresholds and clear of the starting VM "
200+
+ "threshold, so they are being ignored for this deployment. The cluster is short of capacity.");
201+
}
178202
if (!tooBusy.isEmpty()) {
179203
logger.debug("Holding back {} host(s) above their utilisation threshold: {}", tooBusy.size(), tooBusy);
180204
}
205+
if (!backedUp.isEmpty()) {
206+
logger.debug("Holding back {} host(s) with too many VMs still starting: {}", backedUp.size(), backedUp);
207+
}
181208
logger.debug("Weighted host ranking: {}", () -> result.stream()
182209
.filter(h -> scores.containsKey(h.getId()))
183210
.map(h -> String.format("%s=%.4f", h.getName(), scores.get(h.getId())))
@@ -187,11 +214,19 @@ public List<Host> rank(long zoneId, Long podId, Long clusterId, List<? extends H
187214
return result;
188215
}
189216

217+
protected Map<Long, Ternary<Long, Long, Long>> countVmsByHost(long zoneId, Long podId, Long clusterId) {
218+
return vmInstanceDao.countVmsByHost(zoneId, podId, clusterId,
219+
new Date(System.currentTimeMillis() - RecentStartWindow.value() * 1000L));
220+
}
221+
190222
protected Map<Long, Double> score(long zoneId, Long podId, Long clusterId, List<? extends Host> hosts) {
223+
return score(zoneId, clusterId, hosts, countVmsByHost(zoneId, podId, clusterId));
224+
}
225+
226+
protected Map<Long, Double> score(long zoneId, Long clusterId, List<? extends Host> hosts,
227+
Map<Long, Ternary<Long, Long, Long>> vmCounts) {
191228
List<CapacityVO> capacities = capacityDao.listHostCapacityByCapacityTypes(zoneId, clusterId,
192229
List.of(Capacity.CAPACITY_TYPE_CPU, Capacity.CAPACITY_TYPE_MEMORY));
193-
Map<Long, Pair<Long, Long>> vmCounts = vmInstanceDao.countVmsByHost(zoneId, podId, clusterId,
194-
new Date(System.currentTimeMillis() - RecentStartWindow.value() * 1000L));
195230

196231
Map<Long, Double[]> allocated = allocatedFractions(capacities);
197232

@@ -202,7 +237,7 @@ protected Map<Long, Double> score(long zoneId, Long podId, Long clusterId, List<
202237
if (alloc == null) {
203238
continue;
204239
}
205-
Pair<Long, Long> counts = vmCounts.getOrDefault(host.getId(), NO_VMS);
240+
Ternary<Long, Long, Long> counts = vmCounts.getOrDefault(host.getId(), NO_VMS);
206241
scores.put(host.getId(), scoreHost(weights, alloc[0], alloc[1], hostLoadTracker.getLoad(host.getId()),
207242
counts.first(), counts.second()));
208243
}
@@ -330,6 +365,44 @@ protected void partitionByUtilisation(Long clusterId, List<Host> measured, List<
330365
}
331366
}
332367

368+
/**
369+
* Moves hosts with too many VMs still in Starting out of {@code healthy} and into
370+
* {@code backedUp}, least backed up first.
371+
*
372+
* This is the one signal in the algorithm that reacts inside a single deployment. Allocation is
373+
* not charged until a VM reaches Starting, and a utilisation average is a sample interval and a
374+
* half life behind, so neither notices a burst of deployments until it is over. The count of
375+
* VMs in Starting is read fresh on every ranking, and a host that is struggling holds that count
376+
* up for as long as it struggles - which is the point, since a host whose VMs take minutes
377+
* rather than seconds to start is precisely the one that should stop being chosen.
378+
*
379+
* The fallback order within the held-back group is by starting count rather than by score: if
380+
* every host ends up here, the least backed up one is the least bad place to add another.
381+
*/
382+
protected void partitionByStartingVms(Long clusterId, List<Host> healthy,
383+
Map<Long, Ternary<Long, Long, Long>> vmCounts, List<Host> backedUp) {
384+
int threshold = startingVmsThreshold(clusterId);
385+
if (threshold <= 0) {
386+
return;
387+
}
388+
healthy.removeIf(host -> {
389+
if (startingVms(vmCounts, host) >= threshold) {
390+
backedUp.add(host);
391+
return true;
392+
}
393+
return false;
394+
});
395+
backedUp.sort(Comparator.comparingLong(h -> startingVms(vmCounts, h)));
396+
}
397+
398+
protected int startingVmsThreshold(Long clusterId) {
399+
return (int) valueIn(StartingVmsThreshold, clusterId);
400+
}
401+
402+
private static long startingVms(Map<Long, Ternary<Long, Long, Long>> vmCounts, Host host) {
403+
return vmCounts.getOrDefault(host.getId(), NO_VMS).third();
404+
}
405+
333406
/**
334407
* Shuffles the best few hosts so that deployments made at the same moment do not all pick the
335408
* same one. Capacity is only charged once a VM starts, so until then every concurrent decision
@@ -411,7 +484,8 @@ public ConfigKey<?>[] getConfigKeys() {
411484
ExpectedVmsPerHost,
412485
CpuUtilisationThreshold,
413486
MemoryUtilisationThreshold,
414-
SelectionSpread
487+
SelectionSpread,
488+
StartingVmsThreshold
415489
};
416490
}
417491
}

0 commit comments

Comments
 (0)