Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.

ok that covers it

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.

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.

is that cap per management server? with two of them a host could still get 4 backups at once and leave only one worker free

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.

Yes, it is per management server. The count is kept in memory on each one, so with two management servers a host can see up to 2 x the cap, which is 4 at the default and leaves one of the agent's 5 workers free. Making it cluster-wide would mean either a host_id on the backups table (a schema change) or holding a DB lock for the whole backup, which ties up a dedicated MySQL connection for hours per in-flight backup. I did not want either in this PR.

So in 3340496 I have documented it instead. The setting's description now says the cap applies per management server and gives the sizing rule: cap x number of management servers must stay below the agent's workers (agent.properties, default 5). With two management servers, a cap of 1 keeps 3 workers free; with one, the default of 2 does. If you or the other maintainers would rather have a cluster-wide count, I am happy to do it as a follow-up.

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.

ok, the sizing rule in the setting covers it for now. fine to leave a cluster wide count for a follow up

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() {
Comment thread
DaanHoogland marked this conversation as resolved.
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 @@ -35,5 +35,6 @@ public interface BackupDao extends GenericDao<BackupVO, Long> {
List<Backup> syncBackups(Long zoneId, Long vmId, List<Backup> externalBackups);
BackupVO getBackupVO(Backup backup);
List<Backup> listByOfferingId(Long backupOfferingId);
List<BackupVO> listByZoneAndStatus(long zoneId, Backup.Status status);
BackupResponse newBackupResponse(Backup backup);
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public class BackupDaoImpl extends GenericDaoBase<BackupVO, Long> implements Bac
BackupOfferingDao backupOfferingDao;

private SearchBuilder<BackupVO> backupSearch;
private SearchBuilder<BackupVO> zoneStatusSearch;

public BackupDaoImpl() {
}
Expand All @@ -72,6 +73,19 @@ protected void init() {
backupSearch.and("backup_offering_id", backupSearch.entity().getBackupOfferingId(), SearchCriteria.Op.EQ);
backupSearch.and("zone_id", backupSearch.entity().getZoneId(), SearchCriteria.Op.EQ);
backupSearch.done();

zoneStatusSearch = createSearchBuilder();
zoneStatusSearch.and("zone_id", zoneStatusSearch.entity().getZoneId(), SearchCriteria.Op.EQ);
zoneStatusSearch.and("status", zoneStatusSearch.entity().getStatus(), SearchCriteria.Op.EQ);
zoneStatusSearch.done();
}

@Override
public List<BackupVO> listByZoneAndStatus(long zoneId, Backup.Status status) {
SearchCriteria<BackupVO> sc = zoneStatusSearch.create();
sc.setParameters("zone_id", zoneId);
sc.setParameters("status", status);
return listBy(sc);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import com.cloud.storage.dao.VolumeDao;
import com.cloud.utils.Pair;
import com.cloud.utils.component.AdapterBase;
import com.cloud.utils.db.GlobalLock;
import com.cloud.utils.exception.CloudRuntimeException;
import com.cloud.vm.VirtualMachine;
import com.cloud.vm.dao.VMInstanceDao;
Expand All @@ -59,11 +60,52 @@
import java.util.HashMap;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Supplier;
import java.util.stream.Collectors;

public class NASBackupProvider extends AdapterBase implements BackupProvider, Configurable {
private static final Logger LOG = LogManager.getLogger(NASBackupProvider.class);

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 that received them. The limit holds across all management "
+ "servers: in-flight backups are counted from the database under a cluster-wide lock per host. Keep it below the "
+ "agent's worker thread count (workers in agent.properties, 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);

/** Seconds to wait for a host's cluster-wide lock; it is only held for the count-and-reserve step. */
private static final int HOST_SLOT_LOCK_WAIT_SECONDS = 30;

/**
* A BackingUp row older than the take-backup command timeout plus this margin is no longer awaited by any
* management server (for example its management server stopped mid-backup), so it does not hold a slot.
*/
private static final int STALE_RESERVATION_MARGIN_SECONDS = 300;

/** Bounds of the back-off between two attempts to reserve a slot on a full host. */
protected long slotPollMinMillis = 1000L;
protected long slotPollMaxMillis = 15000L;

/** Wakes the waiters on this management server as soon as one of its own backups finishes. */
private final Object localSlotRelease = new Object();

@Inject
private BackupDao backupDao;

Expand Down Expand Up @@ -154,11 +196,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,26 +210,188 @@ public boolean takeBackup(final VirtualMachine vm) {
command.setVolumePaths(volumePaths);
}

BackupAnswer answer = null;
// Concurrent backups are bounded per host across all management servers: the BackingUp row of this
// backup is the reservation of one of the host's slots, created only while the host has a free slot.
// The wait happens here, on the management server, never on the agent's worker threads.
final BackupVO backupVO = parallel
? reserveHostBackupSlot(vm, host.getId(), backupPath, NASBackupParallelMaxPerHost.valueIn(zoneId), NASBackupParallelQueueTimeout.valueIn(zoneId))
: createBackupObject(vm, backupPath);
boolean succeeded = false;
try {
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");
}

if (answer != null && answer.getResult()) {
final BackupAnswer answer;
try {
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");
}
if (answer == null || !answer.getResult()) {
return false;
}
succeeded = true;
backupVO.setDate(new Date());
backupVO.setSize(answer.getSize());
backupVO.setStatus(Backup.Status.BackedUp);
backupVO.setBackedUpVolumes(BackupManagerImpl.createVolumeInfoFromVolumes(volumeDao.findByInstance(vm.getId())));
return backupDao.update(backupVO.getId(), backupVO);
} else {
backupVO.setStatus(Backup.Status.Failed);
backupDao.remove(backupVO.getId());
} finally {
if (!succeeded) {
// Releases the slot: a failed or interrupted backup must never stay BackingUp.

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.

what happens when a backup runs past the 30 min wait? the host keeps going but the record is removed here, so the slot frees up early and the backup goes missing from the list

backupVO.setStatus(Backup.Status.Failed);
backupDao.update(backupVO.getId(), backupVO);
backupDao.remove(backupVO.getId());
}
if (parallel) {
wakeLocalSlotWaiters();
}
}
}

/**
* 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;
}

static String hostBackupLockName(final long hostId) {
return "nas-backup-host-" + hostId;
}

/**
* Reserves one of the host's backup slots and returns the BackingUp backup row that holds it. The row is
* the reservation: it is created only while fewer than maxPerHost NAS backups are in flight on the host,
* counted from the database under the host's cluster-wide lock, so the limit holds across every management
* server. The lock is held only for the count-and-insert step, never for the backup itself. The slot is
* released when the row leaves BackingUp (BackedUp, or Failed and removed). When the host is full this
* waits with back-off, up to timeoutSeconds, then fails with CloudRuntimeException without leaving a row.
*/
protected BackupVO reserveHostBackupSlot(final VirtualMachine vm, final long hostId, final String backupPath,
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 long deadline = System.currentTimeMillis() + timeout * 1000L;
final int[] lastCount = {-1};
long pause = slotPollMinMillis;
while (true) {
final BackupVO reserved = runUnderHostLock(hostId, () -> {
final int inFlight = countInFlightBackups(hostId, vm.getDataCenterId());
lastCount[0] = inFlight;
if (inFlight >= max) {
return null;
}
final BackupVO backup = createBackupObject(vm, backupPath);
LOG.debug("Host {} now has {} of {} NAS backups in flight", hostId, inFlight + 1, max);
return backup;
});
if (reserved != null) {
return reserved;
}
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, Math.max(lastCount[0], 0), max, NASBackupParallelMaxPerHost.key(), NASBackupParallelQueueTimeout.key()));
}
synchronized (localSlotRelease) {
try {
localSlotRelease.wait(Math.max(1L, Math.min(pause, remaining)));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new CloudRuntimeException("Interrupted while waiting for a NAS backup slot on host " + hostId);
}
}
pause = Math.min(pause * 2, slotPollMaxMillis);
}
}

/**
* Runs the action while holding the host's cluster-wide database lock and returns its result, or null when
* the lock could not be taken in time (the caller then retries like it does for a full host).
*/
protected <T> T runUnderHostLock(final long hostId, final Supplier<T> action) {
final GlobalLock lock = GlobalLock.getInternLock(hostBackupLockName(hostId));
try {
if (!lock.lock(HOST_SLOT_LOCK_WAIT_SECONDS)) {
LOG.debug("Could not take lock {} within {} seconds, will retry", lock.getName(), HOST_SLOT_LOCK_WAIT_SECONDS);
return null;
}
try {
return action.get();
} finally {
lock.unlock();
}
} finally {
lock.releaseRef();
}
}

/**
* Counts the NAS backups in flight on the host, from the database, so every management server sees the same
* number. A backup belongs to the host its VM is on, or was last on when stopped, which is how
* getVMHypervisorHost picks the host the command is sent to. A running VM cannot migrate while libvirt runs
* its backup job, so the attribution does not drift for running VMs. Rows older than the take-backup command
* timeout are left out: no management server is waiting for them any more.
*/
protected int countInFlightBackups(final long hostId, final long zoneId) {
final List<BackupVO> backingUp = backupDao.listByZoneAndStatus(zoneId, Backup.Status.BackingUp);
if (CollectionUtils.isEmpty(backingUp)) {
return 0;
}
final long staleBefore = System.currentTimeMillis() - (takeBackupCommandWaitSeconds() + (long) STALE_RESERVATION_MARGIN_SECONDS) * 1000L;
int inFlight = 0;
for (final BackupVO backup : backingUp) {
if (backup.getDate() != null && backup.getDate().getTime() < staleBefore) {
LOG.debug("Not counting stale BackingUp backup {} (started {}) towards host {}", backup.getUuid(), backup.getDate(), hostId);
continue;
}
final BackupOffering offering = backupOfferingDao.findByIdIncludingRemoved(backup.getBackupOfferingId());
if (offering != null && !getName().equalsIgnoreCase(offering.getProvider())) {
continue;
}
final VirtualMachine backupVm = vmInstanceDao.findByIdIncludingRemoved(backup.getVmId());
if (backupVm == null) {
continue;
}
final Long vmHostId = backupVm.getHostId() != null ? backupVm.getHostId() : backupVm.getLastHostId();
if (vmHostId != null && vmHostId == hostId) {
inFlight++;
}
}
return inFlight;
}

/** The longest a management server waits for a TakeBackupCommand answer: commands.timeout if set for it, else wait. */
protected int takeBackupCommandWaitSeconds() {
int wait = Math.max(0, Objects.requireNonNullElse(AgentManager.Wait.value(), 0));
final String granular = AgentManager.GranularWaitTimeForCommands.value();
if (granular != null) {
for (final String entry : granular.split(",")) {
final String[] pair = entry.trim().split("=");
if (pair.length == 2 && TakeBackupCommand.class.getSimpleName().equals(pair[0].trim())) {
try {
final int value = Integer.parseInt(pair[1].trim());
if (value > 0) {
wait = value;
}
} catch (NumberFormatException e) {
LOG.debug("Ignoring invalid commands.timeout entry {}", entry);
}
}
}
}
return wait;
}

protected void wakeLocalSlotWaiters() {
synchronized (localSlotRelease) {
localSlotRelease.notifyAll();
}
return Objects.nonNull(answer) && answer.getResult();
}

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

Expand Down
Loading
Loading