Skip to content

Commit b7ed756

Browse files
Make the DRS affinity check correct
Three defects found reviewing the previous commits, all in how DRS decides whether a planned migration is still allowed. Non-strict anti-affinity was discarded entirely. Non-strict groups express themselves by lowering a host's priority on the deployment plan rather than by excluding it, and DRS built a plan, handed it to the processors, read only the exclude list and threw the plan away. Non-strict means the rule may be broken when there is nowhere else to put a VM. That cannot arise while rebalancing: the VM already runs somewhere that satisfies the group and leaving it there is always an option. Being better balanced is not a reason to break it. A host was treated as free the moment a migration away from it was queued. The jobs are asynchronous and can fail, so a queued migration occupies both ends until it completes. In a swap - A from host1 to host3, B from host2 to host1 - B was cleared for host1 while A was still on it. - track the hosts queued migrations have not actually left - refuse a destination that is one of them A VM that stopped between planning and execution threw NPE inside the affinity check, which was then logged without a stack trace. - treat a VM that is no longer running as an out of date plan - log the exception rather than its message Signed-off-by: Brad House <bhouse@nexthop.ai>
1 parent 3459b70 commit b7ed756

3 files changed

Lines changed: 205 additions & 40 deletions

File tree

plugins/affinity-group-processors/host-anti-affinity/src/test/java/org/apache/cloudstack/affinity/HostAntiAffinityProcessorTest.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040

4141
import static org.junit.Assert.assertFalse;
4242
import static org.junit.Assert.assertTrue;
43+
import static org.mockito.Mockito.lenient;
4344
import static org.mockito.Mockito.when;
4445

4546
@RunWith(JUnit4.class)
@@ -151,10 +152,30 @@ public void testMissingGroupVmIsSkipped() {
151152
public void testRemovedGroupVmIsSkipped() {
152153
when(_vmInstanceDao.findById(GROUP_VM_ID)).thenReturn(groupVM);
153154
when(groupVM.isRemoved()).thenReturn(true);
155+
// a removed VM is not running anywhere, so neither of its hosts should be avoided
156+
lenient().when(groupVM.getHostId()).thenReturn(HOST_ID);
157+
lenient().when(groupVM.getState()).thenReturn(VirtualMachine.State.Stopped);
158+
lenient().when(groupVM.getLastHostId()).thenReturn(LAST_HOST_ID);
159+
lenient().when(groupVM.getUpdateTime()).thenReturn(DateUtil.currentGMTTime());
154160

155161
processor.processAffinityGroup(vmGroupMapping, avoid, vm);
156162

157163
assertFalse(avoids(HOST_ID));
164+
assertFalse(avoids(LAST_HOST_ID));
165+
}
166+
167+
@Test
168+
public void testStartingGroupVmWithReservedCapacityLastHostIsAvoided() {
169+
when(_vmInstanceDao.findById(GROUP_VM_ID)).thenReturn(groupVM);
170+
when(groupVM.isRemoved()).thenReturn(false);
171+
when(groupVM.getHostId()).thenReturn(null);
172+
when(groupVM.getState()).thenReturn(VirtualMachine.State.Starting);
173+
when(groupVM.getLastHostId()).thenReturn(LAST_HOST_ID);
174+
when(groupVM.getUpdateTime()).thenReturn(DateUtil.currentGMTTime());
175+
176+
processor.processAffinityGroup(vmGroupMapping, avoid, vm);
177+
178+
assertTrue(avoids(LAST_HOST_ID));
158179
}
159180

160181
@Test

server/src/main/java/org/apache/cloudstack/cluster/ClusterDrsServiceImpl.java

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import com.cloud.dc.ClusterVO;
2626
import com.cloud.dc.dao.ClusterDao;
2727
import com.cloud.deploy.DataCenterDeployment;
28+
import com.cloud.deploy.DeploymentPlan;
2829
import com.cloud.deploy.DeploymentPlanner.ExcludeList;
2930
import com.cloud.domain.Domain;
3031
import com.cloud.event.ActionEventUtils;
@@ -80,6 +81,7 @@
8081
import org.apache.cloudstack.jobs.JobInfo;
8182
import org.apache.cloudstack.managed.context.ManagedContextTimerTask;
8283
import org.apache.commons.collections.CollectionUtils;
84+
import org.apache.commons.collections.MapUtils;
8385
import org.apache.commons.lang3.time.DateUtils;
8486

8587
import javax.inject.Inject;
@@ -432,6 +434,29 @@ private List<Ternary<VirtualMachine, Host, Host>> getMigrationPlans(
432434
return migrationPlan;
433435
}
434436

437+
/**
438+
* Turns the non-strict affinity preferences recorded on the plan into exclusions.
439+
*
440+
* Non-strict groups express themselves by lowering a host's priority rather than by excluding
441+
* it, and DRS only reads the exclude list - so the preference was being discarded. Rebalancing
442+
* is never a reason to break it: the VM is already running somewhere that satisfies the group,
443+
* and leaving it there is always available to DRS. "Non-strict" means the rule may be broken
444+
* when there is nowhere else to put a VM, which cannot arise while merely rebalancing.
445+
*/
446+
protected void excludeHostsDispreferredByAffinity(DeploymentPlan plan, ExcludeList excludes) {
447+
Map<Long, Integer> priorities = plan.getHostPriorities();
448+
if (MapUtils.isEmpty(priorities)) {
449+
return;
450+
}
451+
for (Map.Entry<Long, Integer> entry : priorities.entrySet()) {
452+
if (entry.getValue() != null && entry.getValue() < DeploymentPlan.DEFAULT_HOST_PRIORITY) {
453+
excludes.addHost(entry.getKey());
454+
logger.debug("Host {} is dispreferred by a non-strict affinity group, so DRS will not migrate onto it",
455+
entry.getKey());
456+
}
457+
}
458+
}
459+
435460
private Map<Long, ExcludeList> getVmToExcludesMap(List<VirtualMachine> vmList, Map<Long, Host> hostMap,
436461
Set<Long> vmsWithAffinityGroups, Map<Long, List<? extends Host>> vmToCompatibleHostsCache,
437462
Map<Long, ServiceOffering> vmIdServiceOfferingMap) {
@@ -452,6 +477,7 @@ private Map<Long, ExcludeList> getVmToExcludesMap(List<VirtualMachine> vmList, M
452477

453478
excludes = managementServer.applyAffinityConstraints(
454479
vm, vmProfile, plan, vmList);
480+
excludeHostsDispreferredByAffinity(plan, excludes);
455481
} else {
456482
// VM has no affinity groups - create minimal ExcludeList (just source host)
457483
excludes = new ExcludeList();
@@ -776,18 +802,35 @@ void processPlans() {
776802
* against current placements, and nothing downstream re-checks it - migrateVirtualMachine does
777803
* not enforce affinity groups.
778804
*
805+
* @param vm
806+
* the VM the plan wants to move
807+
* @param destHost
808+
* where the plan wants to move it
779809
* @param dispatched
780810
* migrations already queued by this run, which the database does not reflect yet
811+
* @param dispatchedSourceHosts
812+
* hosts those queued migrations have not actually left yet
813+
* @return true when the migration should not go ahead
781814
*/
782-
protected boolean destinationViolatesAffinity(VirtualMachine vm, Host destHost, List<VirtualMachine> dispatched) {
815+
protected boolean destinationViolatesAffinity(VirtualMachine vm, Host destHost, List<VirtualMachine> dispatched,
816+
List<Long> dispatchedSourceHosts) {
817+
if (vm.getHostId() == null) {
818+
logger.debug("VM {} is no longer running, so its planned migration is out of date", vm);
819+
return true;
820+
}
783821
if (CollectionUtils.isEmpty(affinityGroupVMMapDao.listByInstanceId(vm.getId()))) {
784822
return false;
785823
}
824+
if (dispatchedSourceHosts.contains(destHost.getId())) {
825+
logger.debug("Host {} is still occupied by a VM whose migration away from it is only queued", destHost);
826+
return true;
827+
}
786828
DataCenterDeployment plan = new DataCenterDeployment(destHost.getDataCenterId(), destHost.getPodId(),
787829
destHost.getClusterId(), null, null, null);
788830
VirtualMachineProfile vmProfile = new VirtualMachineProfileImpl(vm, null,
789831
serviceOfferingDao.findByIdIncludingRemoved(vm.getId(), vm.getServiceOfferingId()), null, null);
790832
ExcludeList excludes = managementServer.applyAffinityConstraints(vm, vmProfile, plan, dispatched);
833+
excludeHostsDispreferredByAffinity(plan, excludes);
791834
return excludes.shouldAvoid(destHost);
792835
}
793836

@@ -806,7 +849,9 @@ void executeDrsPlan(ClusterDrsPlanVO plan) {
806849
plan.setStatus(ClusterDrsPlan.Status.IN_PROGRESS);
807850
drsPlanDao.update(plan.getId(), plan);
808851

852+
// a queued migration occupies both ends until it completes, and it may not complete at all
809853
List<VirtualMachine> dispatched = new ArrayList<>();
854+
List<Long> dispatchedSourceHosts = new ArrayList<>();
810855

811856
for (ClusterDrsPlanMigrationVO migration : planMigrations) {
812857
try {
@@ -817,7 +862,7 @@ void executeDrsPlan(ClusterDrsPlanVO plan) {
817862
migration.getDestHostId()));
818863
}
819864

820-
if (destinationViolatesAffinity(vm, host, dispatched)) {
865+
if (destinationViolatesAffinity(vm, host, dispatched, dispatchedSourceHosts)) {
821866
logger.warn("Skipping DRS migration of vm {} to host {}: it no longer satisfies the affinity " +
822867
"rules for that VM. The plan was generated against older state.", vm, host);
823868
migration.setStatus(JobInfo.Status.FAILED);
@@ -833,11 +878,16 @@ void executeDrsPlan(ClusterDrsPlanVO plan) {
833878
drsPlanMigrationDao.update(migration.getId(), migration);
834879

835880
// the migration job has only been queued, so the database still shows the old host.
836-
// record where it is headed so later migrations in this plan see it.
881+
// record both ends: the VM is headed for the destination but has not left the
882+
// source, and if the job fails it never will.
883+
Long sourceHostId = vm.getHostId();
884+
if (sourceHostId != null) {
885+
dispatchedSourceHosts.add(sourceHostId);
886+
}
837887
vm.setHostId(host.getId());
838888
dispatched.add(vm);
839889
} catch (Exception e) {
840-
logger.warn("Unable to execute DRS plan {} due to {}", plan, e.getMessage());
890+
logger.warn("Unable to execute DRS plan {}", plan, e);
841891
migration.setStatus(JobInfo.Status.FAILED);
842892
drsPlanMigrationDao.update(migration.getId(), migration);
843893
}

0 commit comments

Comments
 (0)