This is an automated email from the ASF dual-hosted git repository.
weizhouapache pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/cloudstack.git
The following commit(s) were added to refs/heads/main by this push:
new ba837fb8a80 Incremental nas backup fixes (#13571)
ba837fb8a80 is described below
commit ba837fb8a801a4175b49533318a6e7533f59edb4
Author: Abhisar Sinha <[email protected]>
AuthorDate: Fri Jul 10 11:50:19 2026 +0530
Incremental nas backup fixes (#13571)
* Avoid concurrent deletes modifying the same backup chain in parallel -
Backup deletes are serialized by using a per-VM GlobalLock
Backup delete checks if it has a live dependent backup not just a live
immediate child.
---
.../cloudstack/backup/NASBackupProvider.java | 187 +++++++---------
.../cloudstack/backup/NASBackupProviderTest.java | 243 ++++++++++++++++++++-
test/integration/smoke/test_backup_recovery_nas.py | 35 +--
3 files changed, 332 insertions(+), 133 deletions(-)
diff --git
a/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java
b/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java
index fe56fbf7c1a..f5fecc5de6f 100644
---
a/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java
+++
b/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java
@@ -38,6 +38,7 @@ import com.cloud.storage.dao.StoragePoolHostDao;
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.user.ResourceLimitService;
import com.cloud.vm.VMInstanceDetailVO;
@@ -83,6 +84,8 @@ import static
org.apache.cloudstack.backup.BackupManager.BackupFrameworkEnabled;
public class NASBackupProvider extends AdapterBase implements BackupProvider,
Configurable {
private static final Logger LOG =
LogManager.getLogger(NASBackupProvider.class);
+ private static final int CHAIN_LOCK_TIMEOUT_SECONDS = 300;
+
ConfigKey<Integer> NASBackupRestoreMountTimeout = new
ConfigKey<>("Advanced", Integer.class,
"nas.backup.restore.mount.timeout",
"30",
@@ -639,7 +642,7 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
}
} else {
logger.error("Failed to take backup for VM {}: {}",
vm.getInstanceName(), answer != null ? answer.getDetails() : "No answer
received");
- if (answer.getNeedsCleanup()) {
+ if (answer != null && answer.getNeedsCleanup()) {
logger.error("Backup cleanup failed for VM {}. Leaving the
backup in Error state. Backup should be manually deleted to free up the space",
vm.getInstanceName());
backupVO.setStatus(Backup.Status.Error);
backupDao.update(backupVO.getId(), backupVO);
@@ -912,26 +915,58 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
return deleteBackupFileAndRow(backup, backupRepository, host);
}
- // Snapshot-style cascade: defer the on-NAS rm + DB row while there
are live children,
- // mark this backup as delete-pending, and let the leaf's deletion
sweep it up later.
- // See DefaultSnapshotStrategy#deleteSnapshotChain for the same
pattern on incremental
- // snapshots. forced=true means the caller wants the entire subtree
gone right now.
- if (forced) {
- return cascadeDeleteSubtree(backup, backupRepository, host);
- }
+ // Concurrent deletes can mutate the same chain concurrently resulting
in inconsistent states.
+ // take a per-VM lock before modifying the chain.
+ final GlobalLock chainLock = acquireChainDeleteLock(backup.getVmId());
+ try {
+ Backup current = backupDao.findById(backup.getId());
+ if (current == null) {
+ LOG.debug("Backup {} was already removed by a concurrent chain
delete", backup.getUuid());
+ return true;
+ }
+ backup = current;
+
+ // Snapshot-style cascade: defer the on-NAS rm + DB row while
there are live children,
+ // mark this backup as delete-pending, and let the leaf's deletion
sweep it up later.
+ // See DefaultSnapshotStrategy#deleteSnapshotChain for the same
pattern on incremental
+ // snapshots. forced=true means the caller wants the entire
subtree gone right now.
+ if (forced) {
+ return cascadeDeleteSubtree(backup, backupRepository, host);
+ }
- List<Backup> liveChildren = findLiveChildren(backup);
- if (!liveChildren.isEmpty()) {
- markDeletePending(backup);
- LOG.debug("Backup {} has {} live child backup(s); marking as
delete-pending. The on-NAS file " +
- "and DB row will be removed once the last
descendant is gone, or pass forced=true.",
- backup.getUuid(), liveChildren.size());
- return true;
+ if (hasLiveChildren(backup)) {
+ markDeletePending(backup);
+ LOG.debug("Backup {} has live descendants in its chain;
setting status as Hidden. " +
+ "The on-NAS file and DB row will be removed
once the last descendant is gone, " +
+ "or pass forced=true.",
+ backup.getUuid());
+ return true;
+ }
+
+ // No live children — physically delete this backup, then walk up
the chain and
+ // collect any ancestors that were left in Hidden state.
+ return deleteLeafBackupAndSweepPendingAncestors(backup,
backupRepository, host);
+ } finally {
+ releaseChainDeleteLock(chainLock);
}
+ }
+
+ /**
+ * Take the per-VM lock that serializes chain-mutating backup deletes.
+ */
+ protected GlobalLock acquireChainDeleteLock(long vmId) {
+ GlobalLock lock = GlobalLock.getInternLock("nas.backup.chain.vm." +
vmId);
+ if (!lock.lock(CHAIN_LOCK_TIMEOUT_SECONDS)) {
+ lock.releaseRef();
+ throw new CloudRuntimeException(String.format(
+ "Timed out waiting for concurrent backup chain operations
on VM %d to finish, please try again", vmId));
+ }
+ return lock;
+ }
- // No live children — physically delete this backup, then walk up the
chain and
- // collect any ancestors that were left in delete-pending state.
- return deleteLeafBackupAndSweepPendingAncestors(backup,
backupRepository, host);
+ protected void releaseChainDeleteLock(GlobalLock lock) {
+ lock.unlock();
+ lock.releaseRef();
}
/**
@@ -951,7 +986,7 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
throw new CloudRuntimeException("Operation to delete backup timed
out, please try again");
}
if (answer == null || !answer.getResult()) {
- logger.warn("Failed to delete backup file for {} ({}); leaving DB
row intact",
+ logger.error("Failed to delete backup file for {} ({}); leaving DB
row intact",
backup.getUuid(), backup.getExternalId());
return false;
}
@@ -1000,53 +1035,24 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
}
/**
- * Return the live (not delete-pending, not Removed) children of {@code
parent} within the
- * same chain. Equivalent to "incrementals whose parent_backup_id points
at parent".
+ * Whether there are any live (not tombstoned/Hidden, not Removed)
descendants of {@code backup} within the
+ * same chain. This uses {@code CHAIN_POSITION} to detect dependents
deeper in the chain.
*/
- private List<Backup> findLiveChildren(Backup parent) {
- String parentUuid = parent.getUuid();
- String chainId = readDetail(parent, NASBackupChainKeys.CHAIN_ID);
- if (parentUuid == null || chainId == null) {
- return Collections.emptyList();
- }
- List<Backup> children = new ArrayList<>();
- for (Backup b : backupDao.listByVmId(null, parent.getVmId())) {
- if (b.getId() == parent.getId()) {
- continue;
- }
- if (!chainId.equals(readDetail(b, NASBackupChainKeys.CHAIN_ID))) {
- continue;
- }
- if (!parentUuid.equals(readDetail(b,
NASBackupChainKeys.PARENT_BACKUP_ID))) {
- continue;
- }
- if (isDeletePending(b)) {
- // Tombstoned children don't keep us alive — they're already
on the way out.
- continue;
- }
- children.add(b);
- }
- return children;
- }
-
- /**
- * Look up this backup's immediate parent in the chain (by {@code
PARENT_BACKUP_ID}).
- * Returns {@code null} if this is the full (no parent) or the parent row
is gone.
- *
- * <p>Prefer {@link #getChainOrderedLeafToRoot(Backup)} when walking the
whole chain —
- * this method hits the DB on each call and is O(N²) when used in a loop.
- */
- private Backup findChainParent(Backup backup) {
- String parentUuid = readDetail(backup,
NASBackupChainKeys.PARENT_BACKUP_ID);
- if (parentUuid == null || parentUuid.isEmpty()) {
- return null;
+ private boolean hasLiveChildren(Backup backup) {
+ String chainId = readDetail(backup, NASBackupChainKeys.CHAIN_ID);
+ if (chainId == null) {
+ return false;
}
+ int position = chainPosition(backup);
for (Backup b : backupDao.listByVmId(null, backup.getVmId())) {
- if (parentUuid.equals(b.getUuid())) {
- return b;
+ if (b.getId() == backup.getId() || !chainId.equals(readDetail(b,
NASBackupChainKeys.CHAIN_ID))) {
+ continue;
+ }
+ if (!isDeletePending(b) && chainPosition(b) > position) {
+ return true;
}
}
- return null;
+ return false;
}
/**
@@ -1078,7 +1084,7 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
/**
* Physically delete the leaf {@code backup}, then walk up the chain while
each ancestor
- * is in delete-pending state. Mirrors the snapshot subsystem pattern:
once a leaf is
+ * is in Hidden state. Mirrors the snapshot subsystem pattern: once a leaf
is
* gone, garbage-collect any tombstoned parents.
*
* <p>Caller must guarantee {@code backup} is a leaf (no live children).
Each tombstoned
@@ -1092,23 +1098,14 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
if (!deleteBackupFileAndRow(backup, repo, host)) {
return false;
}
- // Walk the snapshot from leaf+1 upward, deleting tombstoned ancestors
until a live
- // one is reached or the root is past.
- int leafIdx = indexOfBackupById(chain, backup.getId());
- if (leafIdx < 0) {
- // Leaf wasn't in its own CHAIN_ID list — degenerate case, nothing
more to sweep.
- return true;
- }
- for (int i = leafIdx + 1; i < chain.size(); i++) {
- Backup ancestor = chain.get(i);
- if (!isDeletePending(ancestor)) {
- break;
+ for (Backup member : chain) {
+ if (member.getId() == backup.getId()) {
+ continue;
}
- if (!deleteBackupFileAndRow(ancestor, repo, host)) {
- // Stop the sweep; the rest of the tombstoned chain will be
collected on a
- // future delete that re-runs the sweep.
- return true;
+ if (!isDeletePending(member)) {
+ break;
}
+ deleteBackupFileAndRow(member, repo, host);
}
return true;
}
@@ -1127,42 +1124,6 @@ public class NASBackupProvider extends AdapterBase
implements BackupProvider, Co
return true;
}
- private static int indexOfBackupById(List<Backup> chain, long id) {
- for (int i = 0; i < chain.size(); i++) {
- if (chain.get(i).getId() == id) {
- return i;
- }
- }
- return -1;
- }
-
- /**
- * Return the backup with the highest {@code CHAIN_POSITION} sharing
{@code root}'s
- * {@code CHAIN_ID}. Returns {@code root} if it has no chain metadata or
is itself the tail.
- */
- private Backup findChainTail(Backup root) {
- String chainId = readDetail(root, NASBackupChainKeys.CHAIN_ID);
- if (chainId == null) {
- return root;
- }
- Backup tail = root;
- int tailPos = chainPosition(root);
- for (Backup b : backupDao.listByVmId(null, root.getVmId())) {
- if (b.getId() == root.getId()) {
- continue;
- }
- if (!chainId.equals(readDetail(b, NASBackupChainKeys.CHAIN_ID))) {
- continue;
- }
- int pos = chainPosition(b);
- if (pos > tailPos) {
- tail = b;
- tailPos = pos;
- }
- }
- return tail;
- }
-
private int chainPosition(Backup b) {
String s = readDetail(b, NASBackupChainKeys.CHAIN_POSITION);
if (s == null) {
diff --git
a/plugins/backup/nas/src/test/java/org/apache/cloudstack/backup/NASBackupProviderTest.java
b/plugins/backup/nas/src/test/java/org/apache/cloudstack/backup/NASBackupProviderTest.java
index 3ba7dbad041..cd08378b926 100644
---
a/plugins/backup/nas/src/test/java/org/apache/cloudstack/backup/NASBackupProviderTest.java
+++
b/plugins/backup/nas/src/test/java/org/apache/cloudstack/backup/NASBackupProviderTest.java
@@ -55,6 +55,7 @@ import com.cloud.storage.dao.DiskOfferingDao;
import com.cloud.storage.dao.VolumeDao;
import com.cloud.user.ResourceLimitService;
import com.cloud.utils.Pair;
+import com.cloud.utils.db.GlobalLock;
import com.cloud.vm.VMInstanceDetailVO;
import com.cloud.vm.VMInstanceVO;
import com.cloud.vm.VirtualMachine;
@@ -679,9 +680,11 @@ public class NASBackupProviderTest {
// CHAIN_ID on the parent => not the no-chain fast path.
BackupDetailVO chainIdDetail = new BackupDetailVO(50L,
NASBackupChainKeys.CHAIN_ID, "chain-1", true);
+ BackupDetailVO chainPosDetail = new BackupDetailVO(50L,
NASBackupChainKeys.CHAIN_POSITION, "0", true);
Mockito.when(backupDetailsDao.findDetail(50L,
NASBackupChainKeys.CHAIN_ID)).thenReturn(chainIdDetail);
+ Mockito.when(backupDetailsDao.findDetail(50L,
NASBackupChainKeys.CHAIN_POSITION)).thenReturn(chainPosDetail);
- // A live child references parent-uuid via PARENT_BACKUP_ID.
+ // A live child sits deeper in the chain (higher CHAIN_POSITION).
BackupVO child = new BackupVO();
child.setVmId(vmId);
child.setBackupOfferingId(offeringId);
@@ -692,13 +695,14 @@ public class NASBackupProviderTest {
ReflectionTestUtils.setField(child, "uuid", "child-uuid");
BackupDetailVO childChainId = new BackupDetailVO(51L,
NASBackupChainKeys.CHAIN_ID, "chain-1", true);
- BackupDetailVO childParent = new BackupDetailVO(51L,
NASBackupChainKeys.PARENT_BACKUP_ID, "parent-uuid", true);
+ BackupDetailVO childChainPos = new BackupDetailVO(51L,
NASBackupChainKeys.CHAIN_POSITION, "1", true);
Mockito.when(backupDetailsDao.findDetail(51L,
NASBackupChainKeys.CHAIN_ID)).thenReturn(childChainId);
- Mockito.when(backupDetailsDao.findDetail(51L,
NASBackupChainKeys.PARENT_BACKUP_ID)).thenReturn(childParent);
+ Mockito.when(backupDetailsDao.findDetail(51L,
NASBackupChainKeys.CHAIN_POSITION)).thenReturn(childChainPos);
Mockito.when(backupDao.listByVmId(null,
vmId)).thenReturn(List.of(parent, child));
- // markDeletePending loads the row to flip its status to Hidden.
+ // Re-read under the chain lock + markDeletePending both load the row
by id.
Mockito.when(backupDao.findById(50L)).thenReturn(parent);
+
Mockito.doReturn(mock(GlobalLock.class)).when(nasBackupProvider).acquireChainDeleteLock(vmId);
boolean result = nasBackupProvider.deleteBackup(parent, false);
Assert.assertTrue(result);
@@ -771,13 +775,13 @@ public class NASBackupProviderTest {
parent.setStatus(Backup.Status.Hidden);
Mockito.when(backupDetailsDao.findDetail(50L,
NASBackupChainKeys.CHAIN_ID)).thenReturn(parentChainId);
Mockito.when(backupDetailsDao.findDetail(50L,
NASBackupChainKeys.CHAIN_POSITION)).thenReturn(parentChainPos);
- // Parent has no parent of its own (it's the full anchor).
- Mockito.when(backupDetailsDao.findDetail(50L,
NASBackupChainKeys.PARENT_BACKUP_ID)).thenReturn(null);
- // listByVmId is called once now (chain snapshot taken before the leaf
delete).
// We still use a mutable list + remove() answer so the DAO contract
is realistic.
java.util.List<Backup> liveBackups = new
java.util.ArrayList<>(List.of(parent, leaf));
Mockito.when(backupDao.listByVmId(null, vmId)).thenAnswer(inv -> new
java.util.ArrayList<>(liveBackups));
+ // The target row is re-read under the chain lock before any chain
decision.
+ Mockito.when(backupDao.findById(51L)).thenReturn(leaf);
+
Mockito.doReturn(mock(GlobalLock.class)).when(nasBackupProvider).acquireChainDeleteLock(vmId);
// Agent acknowledges every delete.
Mockito.when(agentManager.send(Mockito.anyLong(),
Mockito.any(DeleteBackupCommand.class)))
@@ -805,4 +809,229 @@ public class NASBackupProviderTest {
Mockito.verify(resourceLimitMgr, Mockito.times(2))
.decrementResourceCount(Mockito.anyLong(),
Mockito.eq(Resource.ResourceType.backup_storage), Mockito.any());
}
+
+ @Test
+ public void deletingLastLiveMemberCollectsDeeperOrphanTombstones()
+ throws AgentUnavailableException, OperationTimedoutException {
+ Long zoneId = 1L;
+ Long vmId = 2L;
+ Long hostId = 3L;
+ Long offeringId = 4L;
+
+ BackupVO full = new BackupVO();
+ full.setVmId(vmId);
+ full.setBackupOfferingId(offeringId);
+ full.setExternalId("i-2-2-VM/2026.05.10.10.00.00");
+ full.setZoneId(zoneId);
+ full.setStatus(Backup.Status.BackedUp);
+ ReflectionTestUtils.setField(full, "id", 50L);
+
+ // The last live incremental — the one being deleted.
+ BackupVO inc1 = new BackupVO();
+ inc1.setVmId(vmId);
+ inc1.setBackupOfferingId(offeringId);
+ inc1.setExternalId("i-2-2-VM/2026.05.10.10.30.00");
+ inc1.setZoneId(zoneId);
+ inc1.setStatus(Backup.Status.BackedUp);
+ ReflectionTestUtils.setField(inc1, "id", 51L);
+
+ // A stranded tombstone deeper in the chain: its own descendants are
long gone, so
+ // only a sweep triggered by an ancestor's deletion can ever collect
it.
+ BackupVO orphan = new BackupVO();
+ orphan.setVmId(vmId);
+ orphan.setBackupOfferingId(offeringId);
+ orphan.setExternalId("i-2-2-VM/2026.05.10.11.00.00");
+ orphan.setZoneId(zoneId);
+ orphan.setStatus(Backup.Status.Hidden);
+ ReflectionTestUtils.setField(orphan, "id", 52L);
+
+ VMInstanceVO vm = mock(VMInstanceVO.class);
+ Mockito.when(vm.getLastHostId()).thenReturn(hostId);
+ HostVO host = mock(HostVO.class);
+ Mockito.when(host.getStatus()).thenReturn(Status.Up);
+ Mockito.when(host.getId()).thenReturn(hostId);
+ Mockito.when(hostDao.findById(hostId)).thenReturn(host);
+
+ BackupRepositoryVO repo = new BackupRepositoryVO(1L, "nas",
"test-repo",
+ "nfs", "address", "sync", 1024L, null);
+
Mockito.when(backupRepositoryDao.findByBackupOfferingId(offeringId)).thenReturn(repo);
+
Mockito.when(vmInstanceDao.findByIdIncludingRemoved(vmId)).thenReturn(vm);
+
+ Mockito.when(backupDetailsDao.findDetail(50L,
NASBackupChainKeys.CHAIN_ID))
+ .thenReturn(new BackupDetailVO(50L,
NASBackupChainKeys.CHAIN_ID, "chain-1", true));
+ Mockito.when(backupDetailsDao.findDetail(50L,
NASBackupChainKeys.CHAIN_POSITION))
+ .thenReturn(new BackupDetailVO(50L,
NASBackupChainKeys.CHAIN_POSITION, "0", true));
+ Mockito.when(backupDetailsDao.findDetail(51L,
NASBackupChainKeys.CHAIN_ID))
+ .thenReturn(new BackupDetailVO(51L,
NASBackupChainKeys.CHAIN_ID, "chain-1", true));
+ Mockito.when(backupDetailsDao.findDetail(51L,
NASBackupChainKeys.CHAIN_POSITION))
+ .thenReturn(new BackupDetailVO(51L,
NASBackupChainKeys.CHAIN_POSITION, "1", true));
+ Mockito.when(backupDetailsDao.findDetail(52L,
NASBackupChainKeys.CHAIN_ID))
+ .thenReturn(new BackupDetailVO(52L,
NASBackupChainKeys.CHAIN_ID, "chain-1", true));
+ Mockito.when(backupDetailsDao.findDetail(52L,
NASBackupChainKeys.CHAIN_POSITION))
+ .thenReturn(new BackupDetailVO(52L,
NASBackupChainKeys.CHAIN_POSITION, "2", true));
+
+ Mockito.when(backupDao.listByVmId(null,
vmId)).thenReturn(List.of(full, inc1, orphan));
+ Mockito.when(backupDao.findById(51L)).thenReturn(inc1);
+
Mockito.doReturn(mock(GlobalLock.class)).when(nasBackupProvider).acquireChainDeleteLock(vmId);
+
+ Mockito.when(agentManager.send(Mockito.anyLong(),
Mockito.any(DeleteBackupCommand.class)))
+ .thenReturn(new BackupAnswer(new DeleteBackupCommand(null,
null, null, null), true, "ok"));
+
+ Assert.assertTrue(nasBackupProvider.deleteBackup(inc1, false));
+
+ // inc1 and the deeper orphan tombstone are physically removed; the
live full survives.
+ Mockito.verify(agentManager, Mockito.times(2))
+ .send(Mockito.anyLong(),
Mockito.any(DeleteBackupCommand.class));
+ Mockito.verify(backupDao).remove(51L);
+ Mockito.verify(backupDao).remove(52L);
+ Mockito.verify(backupDao, Mockito.never()).remove(50L);
+ }
+
+ @Test
+ public void deletingAncestorOfTombstoneWithLiveDescendantTombstonesIt()
+ throws AgentUnavailableException, OperationTimedoutException {
+ Long zoneId = 1L;
+ Long vmId = 2L;
+ Long hostId = 3L;
+ Long offeringId = 4L;
+
+ BackupVO full = new BackupVO();
+ full.setVmId(vmId);
+ full.setBackupOfferingId(offeringId);
+ full.setExternalId("i-2-2-VM/2026.05.10.10.00.00");
+ full.setZoneId(zoneId);
+ full.setStatus(Backup.Status.BackedUp);
+ ReflectionTestUtils.setField(full, "id", 50L);
+
+ BackupVO inc1 = new BackupVO();
+ inc1.setVmId(vmId);
+ inc1.setBackupOfferingId(offeringId);
+ inc1.setExternalId("i-2-2-VM/2026.05.10.10.30.00");
+ inc1.setZoneId(zoneId);
+ inc1.setStatus(Backup.Status.Hidden);
+ ReflectionTestUtils.setField(inc1, "id", 51L);
+
+ BackupVO inc2 = new BackupVO();
+ inc2.setVmId(vmId);
+ inc2.setBackupOfferingId(offeringId);
+ inc2.setExternalId("i-2-2-VM/2026.05.10.11.00.00");
+ inc2.setZoneId(zoneId);
+ inc2.setStatus(Backup.Status.BackedUp);
+ ReflectionTestUtils.setField(inc2, "id", 52L);
+
+ VMInstanceVO vm = mock(VMInstanceVO.class);
+ Mockito.when(vm.getLastHostId()).thenReturn(hostId);
+ HostVO host = mock(HostVO.class);
+ Mockito.when(host.getStatus()).thenReturn(Status.Up);
+ Mockito.when(hostDao.findById(hostId)).thenReturn(host);
+
+ BackupRepositoryVO repo = new BackupRepositoryVO(1L, "nas",
"test-repo",
+ "nfs", "address", "sync", 1024L, null);
+
Mockito.when(backupRepositoryDao.findByBackupOfferingId(offeringId)).thenReturn(repo);
+
Mockito.when(vmInstanceDao.findByIdIncludingRemoved(vmId)).thenReturn(vm);
+
+ Mockito.when(backupDetailsDao.findDetail(50L,
NASBackupChainKeys.CHAIN_ID))
+ .thenReturn(new BackupDetailVO(50L,
NASBackupChainKeys.CHAIN_ID, "chain-1", true));
+ Mockito.when(backupDetailsDao.findDetail(50L,
NASBackupChainKeys.CHAIN_POSITION))
+ .thenReturn(new BackupDetailVO(50L,
NASBackupChainKeys.CHAIN_POSITION, "0", true));
+ Mockito.when(backupDetailsDao.findDetail(51L,
NASBackupChainKeys.CHAIN_ID))
+ .thenReturn(new BackupDetailVO(51L,
NASBackupChainKeys.CHAIN_ID, "chain-1", true));
+ Mockito.when(backupDetailsDao.findDetail(52L,
NASBackupChainKeys.CHAIN_ID))
+ .thenReturn(new BackupDetailVO(52L,
NASBackupChainKeys.CHAIN_ID, "chain-1", true));
+ Mockito.when(backupDetailsDao.findDetail(52L,
NASBackupChainKeys.CHAIN_POSITION))
+ .thenReturn(new BackupDetailVO(52L,
NASBackupChainKeys.CHAIN_POSITION, "2", true));
+
+ Mockito.when(backupDao.listByVmId(null,
vmId)).thenReturn(List.of(full, inc1, inc2));
+ Mockito.when(backupDao.findById(50L)).thenReturn(full);
+
Mockito.doReturn(mock(GlobalLock.class)).when(nasBackupProvider).acquireChainDeleteLock(vmId);
+
+ Assert.assertTrue(nasBackupProvider.deleteBackup(full, false));
+
+ // The full anchor must NOT be physically deleted — inc2 (live) still
restores
+ // through inc1's and full's files. It becomes a tombstone instead.
+ Mockito.verify(agentManager, Mockito.never()).send(Mockito.anyLong(),
Mockito.any(DeleteBackupCommand.class));
+ Mockito.verify(backupDao, Mockito.never()).remove(Mockito.anyLong());
+ ArgumentCaptor<BackupVO> captor =
ArgumentCaptor.forClass(BackupVO.class);
+ Mockito.verify(backupDao).update(Mockito.eq(50L), captor.capture());
+ Assert.assertEquals(Backup.Status.Hidden,
captor.getValue().getStatus());
+ }
+
+ @Test
+ public void sweepContinuesPastFailedTombstoneDelete()
+ throws AgentUnavailableException, OperationTimedoutException {
+ Long zoneId = 1L;
+ Long vmId = 2L;
+ Long hostId = 3L;
+ Long offeringId = 4L;
+
+ BackupVO full = new BackupVO();
+ full.setVmId(vmId);
+ full.setBackupOfferingId(offeringId);
+ full.setExternalId("i-2-2-VM/2026.05.10.10.00.00");
+ full.setZoneId(zoneId);
+ full.setStatus(Backup.Status.Hidden);
+ ReflectionTestUtils.setField(full, "id", 50L);
+
+ BackupVO inc1 = new BackupVO();
+ inc1.setVmId(vmId);
+ inc1.setBackupOfferingId(offeringId);
+ inc1.setExternalId("i-2-2-VM/2026.05.10.10.30.00");
+ inc1.setZoneId(zoneId);
+ inc1.setStatus(Backup.Status.Hidden);
+ ReflectionTestUtils.setField(inc1, "id", 51L);
+
+ // The last live member — deleting it triggers the sweep of both
tombstones.
+ BackupVO inc2 = new BackupVO();
+ inc2.setVmId(vmId);
+ inc2.setBackupOfferingId(offeringId);
+ inc2.setExternalId("i-2-2-VM/2026.05.10.11.00.00");
+ inc2.setZoneId(zoneId);
+ inc2.setStatus(Backup.Status.BackedUp);
+ ReflectionTestUtils.setField(inc2, "id", 52L);
+
+ VMInstanceVO vm = mock(VMInstanceVO.class);
+ Mockito.when(vm.getLastHostId()).thenReturn(hostId);
+ HostVO host = mock(HostVO.class);
+ Mockito.when(host.getStatus()).thenReturn(Status.Up);
+ Mockito.when(host.getId()).thenReturn(hostId);
+ Mockito.when(hostDao.findById(hostId)).thenReturn(host);
+
+ BackupRepositoryVO repo = new BackupRepositoryVO(1L, "nas",
"test-repo",
+ "nfs", "address", "sync", 1024L, null);
+
Mockito.when(backupRepositoryDao.findByBackupOfferingId(offeringId)).thenReturn(repo);
+
Mockito.when(vmInstanceDao.findByIdIncludingRemoved(vmId)).thenReturn(vm);
+
+ Mockito.when(backupDetailsDao.findDetail(50L,
NASBackupChainKeys.CHAIN_ID))
+ .thenReturn(new BackupDetailVO(50L,
NASBackupChainKeys.CHAIN_ID, "chain-1", true));
+ Mockito.when(backupDetailsDao.findDetail(50L,
NASBackupChainKeys.CHAIN_POSITION))
+ .thenReturn(new BackupDetailVO(50L,
NASBackupChainKeys.CHAIN_POSITION, "0", true));
+ Mockito.when(backupDetailsDao.findDetail(51L,
NASBackupChainKeys.CHAIN_ID))
+ .thenReturn(new BackupDetailVO(51L,
NASBackupChainKeys.CHAIN_ID, "chain-1", true));
+ Mockito.when(backupDetailsDao.findDetail(51L,
NASBackupChainKeys.CHAIN_POSITION))
+ .thenReturn(new BackupDetailVO(51L,
NASBackupChainKeys.CHAIN_POSITION, "1", true));
+ Mockito.when(backupDetailsDao.findDetail(52L,
NASBackupChainKeys.CHAIN_ID))
+ .thenReturn(new BackupDetailVO(52L,
NASBackupChainKeys.CHAIN_ID, "chain-1", true));
+ Mockito.when(backupDetailsDao.findDetail(52L,
NASBackupChainKeys.CHAIN_POSITION))
+ .thenReturn(new BackupDetailVO(52L,
NASBackupChainKeys.CHAIN_POSITION, "2", true));
+
+ Mockito.when(backupDao.listByVmId(null,
vmId)).thenReturn(List.of(full, inc1, inc2));
+ Mockito.when(backupDao.findById(52L)).thenReturn(inc2);
+
Mockito.doReturn(mock(GlobalLock.class)).when(nasBackupProvider).acquireChainDeleteLock(vmId);
+
+ // Deletes run leaf-to-root: inc2 succeeds, inc1's rm FAILS, full
succeeds.
+ DeleteBackupCommand dummy = new DeleteBackupCommand(null, null, null,
null);
+ Mockito.when(agentManager.send(Mockito.anyLong(),
Mockito.any(DeleteBackupCommand.class)))
+ .thenReturn(new BackupAnswer(dummy, true, "ok"),
+ new BackupAnswer(dummy, false, "rm failed"),
+ new BackupAnswer(dummy, true, "ok"));
+
+ Assert.assertTrue(nasBackupProvider.deleteBackup(inc2, false));
+
+ // All three members were attempted; the failed inc1 keeps its row,
full is collected.
+ Mockito.verify(agentManager, Mockito.times(3))
+ .send(Mockito.anyLong(),
Mockito.any(DeleteBackupCommand.class));
+ Mockito.verify(backupDao).remove(52L);
+ Mockito.verify(backupDao, Mockito.never()).remove(51L);
+ Mockito.verify(backupDao).remove(50L);
+ }
}
diff --git a/test/integration/smoke/test_backup_recovery_nas.py
b/test/integration/smoke/test_backup_recovery_nas.py
index e55c1b6f0f9..41aa82bd085 100644
--- a/test/integration/smoke/test_backup_recovery_nas.py
+++ b/test/integration/smoke/test_backup_recovery_nas.py
@@ -56,13 +56,17 @@ class TestNASBackupAndRecovery(cloudstackTestCase):
# Check backup configuration values, set them to enable the nas
provider
backup_enabled_cfg = Configurations.list(cls.api_client,
name='backup.framework.enabled')
backup_provider_cfg = Configurations.list(cls.api_client,
name='backup.framework.provider.plugin')
+ incremental_backup_enabled_cfg = Configurations.list(cls.api_client,
name='nas.backup.incremental.enabled')
cls.backup_enabled = backup_enabled_cfg[0].value
cls.backup_provider = backup_provider_cfg[0].value
+ cls.incremental_backup_enabled =
incremental_backup_enabled_cfg[0].value
if cls.backup_enabled == "false":
cls.skipTest(cls, reason="Test can be run only if the config
backup.framework.enabled is true")
if cls.backup_provider != "nas":
Configurations.update(cls.api_client,
'backup.framework.provider.plugin', value='nas')
+ if cls.incremental_backup_enabled == "false":
+ Configurations.update(cls.api_client,
'nas.backup.incremental.enabled', value='true')
cls.account = Account.create(cls.api_client, cls.services["account"],
domainid=cls.domain.id)
@@ -94,6 +98,8 @@ class TestNASBackupAndRecovery(cloudstackTestCase):
if cls.backup_provider != "nas":
Configurations.update(cls.api_client,
'backup.framework.provider.plugin', value=cls.backup_provider)
+ if cls.incremental_backup_enabled == "false":
+ Configurations.update(cls.api_client,
'nas.backup.incremental.enabled', value="false")
except Exception as e:
raise Exception("Warning: Exception during cleanup : %s" % e)
@@ -519,10 +525,11 @@ class TestNASBackupAndRecovery(cloudstackTestCase):
self.backup_offering.removeOffering(self.apiclient, self.vm.id)
@attr(tags=["advanced", "backup"], required_hardware="true")
- def test_refuse_delete_full_with_children(self):
+ def test_delete_full_with_children_is_deferred(self):
"""
- Deleting a FULL that has surviving incrementals must fail without
forced=true.
- With forced=true it must succeed and remove the entire chain.
+ Deleting a FULL that has surviving incrementals succeeds but is
deferred: the
+ FULL is hidden from the backup list while its child survives, and it is
+ physically swept once the last descendant is deleted.
"""
self.backup_offering.assignOffering(self.apiclient, self.vm.id)
original_full_every = self._get_full_every()
@@ -535,19 +542,21 @@ class TestNASBackupAndRecovery(cloudstackTestCase):
backups = Backup.list(self.apiclient, self.vm.id)
backups.sort(key=lambda b: b.created)
- full = backups[0]
+ full, inc = backups[0], backups[1]
- failed = False
- try:
- Backup.delete(self.apiclient, full.id)
- except Exception:
- failed = True
- self.assertTrue(failed, "Deleting a FULL with children should be
refused without forced=true")
+ # Non-forced delete of the FULL is accepted but deferred: the FULL
is
+ # tombstoned (Hidden) and disappears from the list, the child
survives.
+ Backup.delete(self.apiclient, full.id)
+ remaining = Backup.list(self.apiclient, self.vm.id)
+ self.assertEqual(len(remaining), 1,
+ "Deleting a FULL with children should hide the FULL and keep
the child")
+ self.assertEqual(remaining[0].id, inc.id,
+ "The surviving backup should be the incremental child")
- # Forced delete should succeed and clear the whole chain
- Backup.delete(self.apiclient, full.id, forced=True)
+ # Deleting the last descendant sweeps the delete-pending FULL with
it.
+ Backup.delete(self.apiclient, inc.id)
remaining = Backup.list(self.apiclient, self.vm.id)
- self.assertIsNone(remaining, "Forced delete of FULL should remove
the entire chain")
+ self.assertIsNone(remaining, "Deleting the leaf should sweep the
hidden FULL, removing the chain")
finally:
self._set_full_every(original_full_every)
self.backup_offering.removeOffering(self.apiclient, self.vm.id)