Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public class TakeBackupCommand extends Command {
private List<String> volumePaths;
@LogLevel(LogLevel.Log4jLevel.Off)
private String mountOptions;
private boolean executeInSequence = false;

public TakeBackupCommand(String vmName, String backupPath) {
super();
Expand Down Expand Up @@ -89,6 +90,13 @@ public void setVolumePaths(List<String> volumePaths) {

@Override
public boolean executeInSequence() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if a host has 20 VMs on the same backup schedule, do all 20 get sent at once now? is there anything that caps how many run together on one host?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not any more. f0096a6 adds a per-host gate in the provider: at most backup.nas.parallel.max.per.host (default 2) take-backup commands are in flight on a host, and the rest wait on the management server, in the async job thread, bounded by backup.nas.parallel.queue.timeout (default 2 h). A wait that runs out fails cleanly without leaving a BackingUp row behind. So 20 VMs on one schedule on one host run two at a time.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the agent has a small pool of worker threads and everything the mgmt server sends goes through it. if long backups fill it up, does anything else sent to that host wait behind them?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was the real gap, thank you. The cap is deliberately below the agent's default of 5 workers, so at least 3 workers stay free for start, stop, reboot and migrate at all times; an operator who raises the agent's workers can raise the cap to match. The waiting never happens on the agent side.

return true;
// Parallel by default: each backup uses its own per-VM on-NAS path, so concurrent
// runs do not contend. Operators can force sequential execution per zone via the
// backup.nas.parallel.execution.enabled setting.
return executeInSequence;
}

public void setExecuteInSequence(boolean executeInSequence) {
this.executeInSequence = executeInSequence;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package org.apache.cloudstack.backup;

import org.junit.Assert;
import org.junit.Test;

public class TakeBackupCommandTest {

@Test
public void testExecuteInSequenceDefaultsToParallel() {
TakeBackupCommand command = new TakeBackupCommand("vm-1", "/backups/vm-1");
// Default: run in parallel with other backup/delete commands.
Assert.assertFalse(command.executeInSequence());
}

@Test
public void testExecuteInSequenceIsSettable() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this tests the setter. worth testing that the setting actually reaches the command instead?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added NASBackupProviderTest: it drives applyExecutionPolicy() with the setting at true and at false and asserts the command's executeInSequence() follows it, plus four tests on the per-host gate (the cap holds and releases, hosts are independent, a waiter proceeds when a slot frees, release on an unknown host is harmless). The setter test in core stays as the command-level check.

TakeBackupCommand command = new TakeBackupCommand("vm-1", "/backups/vm-1");

command.setExecuteInSequence(true);
Assert.assertTrue(command.executeInSequence());

command.setExecuteInSequence(false);
Assert.assertFalse(command.executeInSequence());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
import org.apache.logging.log4j.LogManager;
import javax.inject.Inject;
import java.text.SimpleDateFormat;
import java.util.concurrent.ConcurrentHashMap;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
Expand All @@ -74,6 +75,35 @@ public class NASBackupProvider extends AdapterBase implements BackupProvider, Co
private BackupOfferingDao backupOfferingDao;

@Inject
public static final ConfigKey<Boolean> NASBackupParallelExecution = new ConfigKey<>("Advanced", Boolean.class,
"backup.nas.parallel.execution.enabled",
"true",
"Let NAS take-backup commands run concurrently on a KVM host instead of queueing behind every earlier command on that host "
+ "and holding up every later one. Concurrency is bounded per host by backup.nas.parallel.max.per.host. "
+ "Disable to restore strictly sequential execution.",
true, ConfigKey.Scope.Zone);

public static final ConfigKey<Integer> NASBackupParallelMaxPerHost = new ConfigKey<>("Advanced", Integer.class,
"backup.nas.parallel.max.per.host",
"2",
"Maximum number of NAS take-backup commands in flight on one KVM host when parallel execution is enabled; further backups "
+ "for that host wait on the management server. Keep it below the agent's worker thread count (default 5) so start, stop, "
+ "reboot and migrate commands are never queued behind backups.",
true, ConfigKey.Scope.Zone);

public static final ConfigKey<Integer> NASBackupParallelQueueTimeout = new ConfigKey<>("Advanced", Integer.class,
"backup.nas.parallel.queue.timeout",
"7200",
"Seconds a NAS take-backup may wait for a free per-host slot before it fails.",
true, ConfigKey.Scope.Zone);

/** In-flight take-backup commands per host, so backups never occupy every agent worker thread. */
private final ConcurrentHashMap<Long, HostBackupSlots> hostBackupSlots = new ConcurrentHashMap<>();

private static final class HostBackupSlots {
private int inFlight;
}

private HostDao hostDao;

@Inject
Expand Down Expand Up @@ -154,11 +184,12 @@ public boolean takeBackup(final VirtualMachine vm) {
final String backupPath = String.format("%s/%s", vm.getInstanceName(),
new SimpleDateFormat("yyyy.MM.dd.HH.mm.ss").format(creationDate));

BackupVO backupVO = createBackupObject(vm, backupPath);
TakeBackupCommand command = new TakeBackupCommand(vm.getInstanceName(), backupPath);
command.setBackupRepoType(backupRepository.getType());
command.setBackupRepoAddress(backupRepository.getAddress());
command.setMountOptions(backupRepository.getMountOptions());
final long zoneId = vm.getDataCenterId();
final boolean parallel = applyExecutionPolicy(command, zoneId);

if (VirtualMachine.State.Stopped.equals(vm.getState())) {
List<VolumeVO> vmVolumes = volumeDao.findByInstance(vm.getId());
Expand All @@ -167,13 +198,26 @@ public boolean takeBackup(final VirtualMachine vm) {
command.setVolumePaths(volumePaths);
}

// Concurrent backups are bounded per host: wait here, on the management server, for one of the
// host's slots so the agent's worker threads are never all taken by long-running backups.
boolean slotHeld = false;
if (parallel) {
acquireHostBackupSlot(host.getId(), NASBackupParallelMaxPerHost.valueIn(zoneId), NASBackupParallelQueueTimeout.valueIn(zoneId));
slotHeld = true;
}
BackupVO backupVO = null;
BackupAnswer answer = null;
try {
backupVO = createBackupObject(vm, backupPath);
answer = (BackupAnswer) agentManager.send(host.getId(), command);
} catch (AgentUnavailableException e) {
throw new CloudRuntimeException("Unable to contact backend control plane to initiate backup");
} catch (OperationTimedoutException e) {
throw new CloudRuntimeException("Operation to initiate backup timed out, please try again");
} finally {
if (slotHeld) {
releaseHostBackupSlot(host.getId());
}
}

if (answer != null && answer.getResult()) {
Expand All @@ -189,6 +233,70 @@ public boolean takeBackup(final VirtualMachine vm) {
return Objects.nonNull(answer) && answer.getResult();
}

/**
* Applies the zone's execution policy to the command and returns true when the command will run
* concurrently with other commands on the host (and so must be gated per host).
*/
protected boolean applyExecutionPolicy(final TakeBackupCommand command, final long zoneId) {
final boolean parallel = Boolean.TRUE.equals(NASBackupParallelExecution.valueIn(zoneId));
command.setExecuteInSequence(!parallel);
return parallel;
}

/**
* Waits for one of the host's backup slots. The caller holds it for the whole agent round-trip and
* releases it in a finally block. The wait happens on the management server (the async job thread),
* never on the agent's worker threads. Fails with CloudRuntimeException once the timeout is reached.
*/
protected void acquireHostBackupSlot(final long hostId, final Integer maxPerHost, final Integer timeoutSeconds) {
final int max = Math.max(1, maxPerHost == null ? 1 : maxPerHost);
final int timeout = Math.max(0, timeoutSeconds == null ? 0 : timeoutSeconds);
final HostBackupSlots slots = hostBackupSlots.computeIfAbsent(hostId, id -> new HostBackupSlots());
final long deadline = System.currentTimeMillis() + timeout * 1000L;
synchronized (slots) {
while (slots.inFlight >= max) {
final long remaining = deadline - System.currentTimeMillis();
if (remaining <= 0) {
throw new CloudRuntimeException(String.format(
"Timed out after %d seconds waiting for a NAS backup slot on host %d (%d of %d in flight); "
+ "raise %s or %s, or retry when the host's backups finish",
timeout, hostId, slots.inFlight, max, NASBackupParallelMaxPerHost.key(), NASBackupParallelQueueTimeout.key()));
}
try {
slots.wait(remaining);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new CloudRuntimeException("Interrupted while waiting for a NAS backup slot on host " + hostId);
}
}
slots.inFlight++;
LOG.debug("Host {} now has {} of {} NAS backups in flight", hostId, slots.inFlight, max);
}
}

protected void releaseHostBackupSlot(final long hostId) {
final HostBackupSlots slots = hostBackupSlots.get(hostId);
if (slots == null) {
return;
}
synchronized (slots) {
if (slots.inFlight > 0) {
slots.inFlight--;
}
slots.notifyAll();
}
}

protected int getInFlightBackups(final long hostId) {
final HostBackupSlots slots = hostBackupSlots.get(hostId);
if (slots == null) {
return 0;
}
synchronized (slots) {
return slots.inFlight;
}
}

private BackupVO createBackupObject(VirtualMachine vm, String backupPath) {
BackupVO backup = new BackupVO();
backup.setVmId(vm.getId());
Expand Down Expand Up @@ -450,6 +558,9 @@ public boolean isValidProviderOffering(Long zoneId, String uuid) {
@Override
public ConfigKey<?>[] getConfigKeys() {
return new ConfigKey[]{
NASBackupParallelExecution,
NASBackupParallelMaxPerHost,
NASBackupParallelQueueTimeout
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package org.apache.cloudstack.backup;

import java.lang.reflect.Field;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;

import org.apache.cloudstack.framework.config.ConfigKey;
import org.junit.Assert;
import org.junit.Test;

import com.cloud.utils.exception.CloudRuntimeException;

public class NASBackupProviderTest {

private static void overrideDefaultConfigValue(final ConfigKey<?> key, final String value) throws Exception {
final Field field = ConfigKey.class.getDeclaredField("_defaultValue");
field.setAccessible(true);
field.set(key, value);
}

@Test
public void executionPolicyReachesTheCommand() throws Exception {
final NASBackupProvider provider = new NASBackupProvider();
final TakeBackupCommand command = new TakeBackupCommand("vm-1", "/backups/vm-1");

overrideDefaultConfigValue(NASBackupProvider.NASBackupParallelExecution, "false");
Assert.assertFalse(provider.applyExecutionPolicy(command, 1L));
Assert.assertTrue("disabled setting must make the command sequential", command.executeInSequence());

overrideDefaultConfigValue(NASBackupProvider.NASBackupParallelExecution, "true");
Assert.assertTrue(provider.applyExecutionPolicy(command, 1L));
Assert.assertFalse("enabled setting must let the command run concurrently", command.executeInSequence());
}

@Test
public void perHostCapHoldsAndReleases() {
final NASBackupProvider provider = new NASBackupProvider();
provider.acquireHostBackupSlot(7L, 2, 1);
provider.acquireHostBackupSlot(7L, 2, 1);
Assert.assertEquals(2, provider.getInFlightBackups(7L));
try {
provider.acquireHostBackupSlot(7L, 2, 1);
Assert.fail("third backup on a host capped at 2 must not get a slot");
} catch (CloudRuntimeException expected) {
Assert.assertTrue(expected.getMessage().contains("host 7"));
}
Assert.assertEquals(2, provider.getInFlightBackups(7L));
provider.releaseHostBackupSlot(7L);
provider.acquireHostBackupSlot(7L, 2, 1);
Assert.assertEquals(2, provider.getInFlightBackups(7L));
provider.releaseHostBackupSlot(7L);
provider.releaseHostBackupSlot(7L);
Assert.assertEquals(0, provider.getInFlightBackups(7L));
}

@Test
public void hostsAreIndependent() {
final NASBackupProvider provider = new NASBackupProvider();
provider.acquireHostBackupSlot(1L, 1, 1);
provider.acquireHostBackupSlot(2L, 1, 1);
Assert.assertEquals(1, provider.getInFlightBackups(1L));
Assert.assertEquals(1, provider.getInFlightBackups(2L));
provider.releaseHostBackupSlot(1L);
provider.releaseHostBackupSlot(2L);
}

@Test
public void waiterProceedsWhenASlotFrees() throws Exception {
final NASBackupProvider provider = new NASBackupProvider();
provider.acquireHostBackupSlot(9L, 1, 10);
final CountDownLatch acquired = new CountDownLatch(1);
final AtomicBoolean failed = new AtomicBoolean(false);
final Thread waiter = new Thread(() -> {
try {
provider.acquireHostBackupSlot(9L, 1, 10);
acquired.countDown();
} catch (CloudRuntimeException e) {
failed.set(true);
}
});
waiter.start();
Assert.assertFalse("waiter must block while the only slot is held", acquired.await(300, TimeUnit.MILLISECONDS));
provider.releaseHostBackupSlot(9L);
Assert.assertTrue("waiter must get the slot once it is released", acquired.await(5, TimeUnit.SECONDS));
Assert.assertFalse(failed.get());
Assert.assertEquals(1, provider.getInFlightBackups(9L));
provider.releaseHostBackupSlot(9L);
waiter.join(1000);
}

@Test
public void releaseOnUnknownHostIsHarmless() {
final NASBackupProvider provider = new NASBackupProvider();
provider.releaseHostBackupSlot(404L);
Assert.assertEquals(0, provider.getInFlightBackups(404L));
}
}
Loading