This is an automated email from the ASF dual-hosted git repository.
taklwu pushed a commit to branch HBASE-28957
in repository https://gitbox.apache.org/repos/asf/hbase.git
The following commit(s) were added to refs/heads/HBASE-28957 by this push:
new a9625998446 HBASE-29375 Add Unit Tests for BackupAdminImpl and Improve
Test Granularity (#7171)
a9625998446 is described below
commit a962599844662c465020b82c8b830ce1fdb05edc
Author: vinayak hegde <[email protected]>
AuthorDate: Tue Jul 29 23:18:47 2025 +0530
HBASE-29375 Add Unit Tests for BackupAdminImpl and Improve Test Granularity
(#7171)
Signed-off-by: Tak Lon (Stephen) Wu <[email protected]>
Reviewed by: Kevin Geiszler <[email protected]>
---
.../hadoop/hbase/backup/impl/BackupAdminImpl.java | 55 +-
.../hbase/backup/impl/TestBackupAdminImpl.java | 750 +++++++++++++++++++++
2 files changed, 795 insertions(+), 10 deletions(-)
diff --git
a/hbase-backup/src/main/java/org/apache/hadoop/hbase/backup/impl/BackupAdminImpl.java
b/hbase-backup/src/main/java/org/apache/hadoop/hbase/backup/impl/BackupAdminImpl.java
index 75a2a6343a5..2122ef9378f 100644
---
a/hbase-backup/src/main/java/org/apache/hadoop/hbase/backup/impl/BackupAdminImpl.java
+++
b/hbase-backup/src/main/java/org/apache/hadoop/hbase/backup/impl/BackupAdminImpl.java
@@ -17,6 +17,7 @@
*/
package org.apache.hadoop.hbase.backup.impl;
+import com.google.errorprone.annotations.RestrictedApi;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
@@ -175,8 +176,11 @@ public class BackupAdminImpl implements BackupAdmin {
* @param table backup system table
* @throws IOException if a table operation fails
*/
- private void finalizeDelete(List<String> backupRoots, BackupSystemTable
table)
- throws IOException {
+ @RestrictedApi(
+ explanation = "Package-private for test visibility only. Do not use
outside tests.",
+ link = "",
+ allowedOnPath =
"(.*/src/test/.*|.*/org/apache/hadoop/hbase/backup/impl/BackupAdminImpl.java)")
+ void finalizeDelete(List<String> backupRoots, BackupSystemTable table)
throws IOException {
for (String backupRoot : backupRoots) {
Set<TableName> incrTableSet =
table.getIncrementalBackupTableSet(backupRoot);
Map<TableName, List<BackupInfo>> tableMap =
@@ -212,7 +216,11 @@ public class BackupAdminImpl implements BackupAdmin {
* @return total number of deleted backup images
* @throws IOException if deleting the backup fails
*/
- private int deleteBackup(String backupId, BackupSystemTable sysTable) throws
IOException {
+ @RestrictedApi(
+ explanation = "Package-private for test visibility only. Do not use
outside tests.",
+ link = "",
+ allowedOnPath =
"(.*/src/test/.*|.*/org/apache/hadoop/hbase/backup/impl/BackupAdminImpl.java)")
+ int deleteBackup(String backupId, BackupSystemTable sysTable) throws
IOException {
BackupInfo backupInfo = sysTable.readBackupInfo(backupId);
int totalDeleted = 0;
@@ -274,7 +282,11 @@ public class BackupAdminImpl implements BackupAdmin {
return totalDeleted;
}
- private void removeTableFromBackupImage(BackupInfo info, TableName tn,
BackupSystemTable sysTable)
+ @RestrictedApi(
+ explanation = "Package-private for test visibility only. Do not use
outside tests.",
+ link = "",
+ allowedOnPath =
"(.*/src/test/.*|.*/org/apache/hadoop/hbase/backup/impl/BackupAdminImpl.java)")
+ void removeTableFromBackupImage(BackupInfo info, TableName tn,
BackupSystemTable sysTable)
throws IOException {
List<TableName> tables = info.getTableNames();
LOG.debug(
@@ -297,7 +309,11 @@ public class BackupAdminImpl implements BackupAdmin {
}
}
- private List<BackupInfo> getAffectedBackupSessions(BackupInfo backupInfo,
TableName tn,
+ @RestrictedApi(
+ explanation = "Package-private for test visibility only. Do not use
outside tests.",
+ link = "",
+ allowedOnPath =
"(.*/src/test/.*|.*/org/apache/hadoop/hbase/backup/impl/BackupAdminImpl.java)")
+ List<BackupInfo> getAffectedBackupSessions(BackupInfo backupInfo, TableName
tn,
BackupSystemTable table) throws IOException {
LOG.debug("GetAffectedBackupInfos for: " + backupInfo.getBackupId() + "
table=" + tn);
long ts = backupInfo.getStartTs();
@@ -329,7 +345,11 @@ public class BackupAdminImpl implements BackupAdmin {
* Clean up the data at target directory
* @throws IOException if cleaning up the backup directory fails
*/
- private void cleanupBackupDir(BackupInfo backupInfo, TableName table,
Configuration conf)
+ @RestrictedApi(
+ explanation = "Package-private for test visibility only. Do not use
outside tests.",
+ link = "",
+ allowedOnPath =
"(.*/src/test/.*|.*/org/apache/hadoop/hbase/backup/impl/BackupAdminImpl.java)")
+ void cleanupBackupDir(BackupInfo backupInfo, TableName table, Configuration
conf)
throws IOException {
try {
// clean up the data at target directory
@@ -339,7 +359,7 @@ public class BackupAdminImpl implements BackupAdmin {
return;
}
- FileSystem outputFs = FileSystem.get(new
Path(backupInfo.getBackupRootDir()).toUri(), conf);
+ FileSystem outputFs = getFileSystem(new
Path(backupInfo.getBackupRootDir()), conf);
Path targetDirPath = new
Path(BackupUtils.getTableBackupDir(backupInfo.getBackupRootDir(),
backupInfo.getBackupId(), table));
@@ -355,7 +375,19 @@ public class BackupAdminImpl implements BackupAdmin {
}
}
- private boolean isLastBackupSession(BackupSystemTable table, TableName tn,
long startTime)
+ @RestrictedApi(
+ explanation = "Package-private for test visibility only. Do not use
outside tests.",
+ link = "",
+ allowedOnPath =
"(.*/src/test/.*|.*/org/apache/hadoop/hbase/backup/impl/BackupAdminImpl.java)")
+ FileSystem getFileSystem(Path path, Configuration conf) throws IOException {
+ return FileSystem.get(path.toUri(), conf);
+ }
+
+ @RestrictedApi(
+ explanation = "Package-private for test visibility only. Do not use
outside tests.",
+ link = "",
+ allowedOnPath =
"(.*/src/test/.*|.*/org/apache/hadoop/hbase/backup/impl/BackupAdminImpl.java)")
+ boolean isLastBackupSession(BackupSystemTable table, TableName tn, long
startTime)
throws IOException {
List<BackupInfo> history = table.getBackupHistory();
for (BackupInfo info : history) {
@@ -682,8 +714,11 @@ public class BackupAdminImpl implements BackupAdmin {
* @param table backup system table
* @throws IOException if the backup image is not valid for merge
*/
- private void checkIfValidForMerge(String[] backupIds, BackupSystemTable
table)
- throws IOException {
+ @RestrictedApi(
+ explanation = "Package-private for test visibility only. Do not use
outside tests.",
+ link = "",
+ allowedOnPath =
"(.*/src/test/.*|.*/org/apache/hadoop/hbase/backup/impl/BackupAdminImpl.java)")
+ void checkIfValidForMerge(String[] backupIds, BackupSystemTable table)
throws IOException {
String backupRoot = null;
final Set<TableName> allTables = new HashSet<>();
diff --git
a/hbase-backup/src/test/java/org/apache/hadoop/hbase/backup/impl/TestBackupAdminImpl.java
b/hbase-backup/src/test/java/org/apache/hadoop/hbase/backup/impl/TestBackupAdminImpl.java
new file mode 100644
index 00000000000..b78d44c144b
--- /dev/null
+++
b/hbase-backup/src/test/java/org/apache/hadoop/hbase/backup/impl/TestBackupAdminImpl.java
@@ -0,0 +1,750 @@
+/*
+ * 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.hadoop.hbase.backup.impl;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anySet;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.FileSystem;
+import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.hbase.HBaseClassTestRule;
+import org.apache.hadoop.hbase.TableName;
+import org.apache.hadoop.hbase.backup.BackupInfo;
+import org.apache.hadoop.hbase.backup.BackupInfo.BackupState;
+import org.apache.hadoop.hbase.backup.BackupType;
+import org.apache.hadoop.hbase.backup.util.BackupUtils;
+import org.apache.hadoop.hbase.client.Connection;
+import org.apache.hadoop.hbase.testclassification.SmallTests;
+import org.junit.Before;
+import org.junit.ClassRule;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
+
+/**
+ * Unit tests for {@link BackupAdminImpl}.
+ * <p>
+ * This class improves test coverage by validating the behavior of key methods
in BackupAdminImpl.
+ * Some methods are made package-private to enable testing.
+ */
+@Category(SmallTests.class)
+public class TestBackupAdminImpl {
+ @ClassRule
+ public static final HBaseClassTestRule CLASS_RULE =
+ HBaseClassTestRule.forClass(TestBackupAdminImpl.class);
+
+ private BackupAdminImpl backupAdminImpl;
+ private BackupSystemTable mockTable;
+
+ @Before
+ public void setUp() {
+ backupAdminImpl = new BackupAdminImpl(null);
+ mockTable = mock(BackupSystemTable.class);
+ }
+
+ /**
+ * Scenario: - The initial incremental table set contains "table1" and
"table2" - Only "table1"
+ * still exists in backup history Expectation: - The backup system should
delete the existing set
+ * - Then re-add a filtered set that includes only "table1"
+ */
+ @Test
+ public void testFinalizeDelete_addsRetainedTablesBack() throws IOException {
+ String backupRoot = "backupRoot1";
+ List<String> backupRoots = Collections.singletonList(backupRoot);
+
+ Set<TableName> initialTableSet =
+ new HashSet<>(Arrays.asList(TableName.valueOf("ns:table1"),
TableName.valueOf("ns:table2")));
+
+ Map<TableName, List<BackupInfo>> backupHistory = new HashMap<>();
+ backupHistory.put(TableName.valueOf("ns:table1"), List.of(new
BackupInfo())); // Only table1
+
// retained
+
+ when(mockTable.getIncrementalBackupTableSet(backupRoot))
+ .thenReturn(new HashSet<>(initialTableSet));
+ when(mockTable.getBackupHistoryForTableSet(initialTableSet, backupRoot))
+ .thenReturn(backupHistory);
+
+ backupAdminImpl.finalizeDelete(backupRoots, mockTable);
+
+ // Always remove existing backup metadata
+ verify(mockTable).deleteIncrementalBackupTableSet(backupRoot);
+
+ // Re-add only retained tables (should be just table1)
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor<Set<TableName>> captor =
+ (ArgumentCaptor<Set<TableName>>) (ArgumentCaptor<?>)
ArgumentCaptor.forClass(Set.class);
+ verify(mockTable).addIncrementalBackupTableSet(captor.capture(),
eq(backupRoot));
+
+ Set<TableName> retained = captor.getValue();
+ assertEquals(1, retained.size());
+ assertTrue(retained.contains(TableName.valueOf("ns:table1")));
+ }
+
+ /**
+ * Scenario: - The incremental table set has "tableX" - No backups exist for
this table
+ * Expectation: - Backup metadata should be deleted - Nothing should be
re-added since no tables
+ * are retained
+ */
+ @Test
+ public void testFinalizeDelete_retainedSetEmpty_doesNotAddBack() throws
IOException {
+ String backupRoot = "backupRoot2";
+ List<String> backupRoots = List.of(backupRoot);
+
+ Set<TableName> initialTableSet = Set.of(TableName.valueOf("ns:tableX"));
+ Map<TableName, List<BackupInfo>> backupHistory = Map.of(); // No overlap
+
+ when(mockTable.getIncrementalBackupTableSet(backupRoot))
+ .thenReturn(new HashSet<>(initialTableSet));
+ when(mockTable.getBackupHistoryForTableSet(initialTableSet, backupRoot))
+ .thenReturn(backupHistory);
+
+ backupAdminImpl.finalizeDelete(backupRoots, mockTable);
+
+ // Delete should be called
+ verify(mockTable).deleteIncrementalBackupTableSet(backupRoot);
+ // No add back since retained set is empty
+ verify(mockTable, never()).addIncrementalBackupTableSet(any(),
eq(backupRoot));
+ }
+
+ /**
+ * Scenario: - Two backup roots: - root1: one table with valid backup
history → should be retained
+ * - root2: one table with no history → should not be retained Expectation:
- root1 metadata
+ * deleted and re-added - root2 metadata only deleted
+ */
+ @Test
+ public void testFinalizeDelete_multipleRoots() throws IOException {
+ String root1 = "root1";
+ String root2 = "root2";
+ List<String> roots = List.of(root1, root2);
+
+ TableName t1 = TableName.valueOf("ns:table1");
+ TableName t2 = TableName.valueOf("ns:table2");
+
+ // root1 setup
+ when(mockTable.getIncrementalBackupTableSet(root1)).thenReturn(new
HashSet<>(List.of(t1)));
+ when(mockTable.getBackupHistoryForTableSet(Set.of(t1), root1))
+ .thenReturn(Map.of(t1, List.of(new BackupInfo())));
+
+ // root2 setup
+ when(mockTable.getIncrementalBackupTableSet(root2)).thenReturn(new
HashSet<>(List.of(t2)));
+ when(mockTable.getBackupHistoryForTableSet(Set.of(t2),
root2)).thenReturn(Map.of()); // empty
+
// history
+
+ backupAdminImpl.finalizeDelete(roots, mockTable);
+
+ // root1: should delete and re-add table
+ verify(mockTable).deleteIncrementalBackupTableSet(root1);
+ verify(mockTable).addIncrementalBackupTableSet(Set.of(t1), root1);
+
+ // root2: delete only
+ verify(mockTable).deleteIncrementalBackupTableSet(root2);
+ verify(mockTable, never()).addIncrementalBackupTableSet(anySet(),
eq(root2));
+ }
+
+ /**
+ * Verifies that {@code cleanupBackupDir} correctly deletes the target
backup directory for a
+ * given table and backup ID using the mocked FileSystem.
+ * <p>
+ * This test ensures: - The correct path is constructed using BackupUtils. -
FileSystem#delete is
+ * invoked with that path.
+ */
+ @Test
+ public void testCleanupBackupDir_deletesTargetDirSuccessfully() throws
Exception {
+ // Setup test input
+ String backupId = "backup_001";
+ String backupRootDir = "/backup/root";
+ TableName table = TableName.valueOf("test_table");
+
+ BackupInfo mockBackupInfo = mock(BackupInfo.class);
+ when(mockBackupInfo.getBackupRootDir()).thenReturn(backupRootDir);
+ when(mockBackupInfo.getBackupId()).thenReturn(backupId);
+
+ Configuration conf = new Configuration();
+
+ // Spy BackupAdminImpl to mock getFileSystem behavior
+ backupAdminImpl = spy(backupAdminImpl);
+
+ FileSystem mockFs = mock(FileSystem.class);
+ Path expectedPath = new Path(BackupUtils.getTableBackupDir(backupRootDir,
backupId, table));
+
+ // Mock getFileSystem to return our mock FileSystem
+ doReturn(mockFs).when(backupAdminImpl).getFileSystem(any(Path.class),
eq(conf));
+ when(mockFs.delete(expectedPath, true)).thenReturn(true);
+
+ // Call the method under test
+ backupAdminImpl.cleanupBackupDir(mockBackupInfo, table, conf);
+
+ // Verify the FileSystem delete call with correct path
+ verify(mockFs).delete(expectedPath, true);
+ }
+
+ /**
+ * Verifies that {@code cleanupBackupDir} throws an {@link IOException} if
the FileSystem
+ * retrieval fails.
+ * <p>
+ * This test simulates an exception while trying to obtain the FileSystem,
and expects the method
+ * to propagate the exception.
+ */
+ @Test(expected = IOException.class)
+ public void testCleanupBackupDir_throwsIOException() throws Exception {
+ // Setup test input
+ String backupId = "backup_003";
+ String backupRootDir = "/backup/root";
+ TableName table = TableName.valueOf("test_table");
+
+ BackupInfo mockBackupInfo = mock(BackupInfo.class);
+ when(mockBackupInfo.getBackupRootDir()).thenReturn(backupRootDir);
+ when(mockBackupInfo.getBackupId()).thenReturn(backupId);
+
+ Configuration conf = new Configuration();
+
+ // Spy BackupAdminImpl to inject failure in getFileSystem
+ backupAdminImpl = spy(backupAdminImpl);
+ doThrow(new IOException("FS
error")).when(backupAdminImpl).getFileSystem(any(Path.class),
+ eq(conf));
+
+ // Call method and expect IOException
+ backupAdminImpl.cleanupBackupDir(mockBackupInfo, table, conf);
+ }
+
+ /**
+ * Tests that when a current incremental backup is found in the history, all
later incremental
+ * backups for the same table are returned. This simulates rolling forward
from the current backup
+ * timestamp, capturing newer incremental backups that depend on it.
+ */
+ @Test
+ public void testGetAffectedBackupSessions() throws IOException {
+ BackupInfo current = mock(BackupInfo.class);
+ TableName table = TableName.valueOf("test_table");
+
+ when(current.getStartTs()).thenReturn(2000L);
+ when(current.getBackupId()).thenReturn("backup_002");
+ when(current.getBackupRootDir()).thenReturn("/backup/root");
+
+ BackupInfo b0 = createBackupInfo("backup_000", 500L, BackupType.FULL,
table);
+ BackupInfo b1 = createBackupInfo("backup_001", 1000L,
BackupType.INCREMENTAL, table);
+ BackupInfo b2 = createBackupInfo("backup_002", 2000L,
BackupType.INCREMENTAL, table); // current
+ BackupInfo b3 = createBackupInfo("backup_003", 3000L,
BackupType.INCREMENTAL, table);
+ BackupInfo b4 = createBackupInfo("backup_004", 4000L,
BackupType.INCREMENTAL, table);
+
+ when(mockTable.getBackupHistory("/backup/root")).thenReturn(List.of(b4,
b3, b2, b1, b0));
+
+ List<BackupInfo> result =
backupAdminImpl.getAffectedBackupSessions(current, table, mockTable);
+
+ assertEquals(2, result.size());
+ assertTrue(result.containsAll(List.of(b3, b4)));
+ }
+
+ /**
+ * Tests that if a full backup appears after the current backup, the
affected list is reset and
+ * incremental backups following that full backup are not included. This
ensures full backups act
+ * as a reset boundary.
+ */
+ @Test
+ public void testGetAffectedBackupSessions_resetsOnFullBackup() throws
IOException {
+ BackupInfo current = mock(BackupInfo.class);
+ TableName table = TableName.valueOf("test_table");
+
+ when(current.getStartTs()).thenReturn(1000L);
+ when(current.getBackupId()).thenReturn("backup_001");
+ when(current.getBackupRootDir()).thenReturn("/backup/root");
+
+ BackupInfo b0 = createBackupInfo("backup_000", 500L, BackupType.FULL,
table);
+ BackupInfo b1 = createBackupInfo("backup_001", 1000L,
BackupType.INCREMENTAL, table); // current
+ BackupInfo b2 = createBackupInfo("backup_002", 2000L, BackupType.FULL,
table);
+ BackupInfo b3 = createBackupInfo("backup_003", 3000L,
BackupType.INCREMENTAL, table);
+
+ when(mockTable.getBackupHistory("/backup/root")).thenReturn(List.of(b3,
b2, b1, b0));
+
+ List<BackupInfo> result =
backupAdminImpl.getAffectedBackupSessions(current, table, mockTable);
+
+ assertTrue(result.isEmpty());
+ }
+
+ /**
+ * Tests that backups for other tables are ignored, even if they are
incremental and fall after
+ * the current backup. Only backups affecting the specified table should be
considered.
+ */
+ @Test
+ public void testGetAffectedBackupSessions_skipsNonMatchingTable() throws
IOException {
+ BackupInfo current = mock(BackupInfo.class);
+ TableName table = TableName.valueOf("test_table");
+
+ when(current.getStartTs()).thenReturn(1000L);
+ when(current.getBackupId()).thenReturn("backup_001");
+ when(current.getBackupRootDir()).thenReturn("/backup/root");
+
+ BackupInfo b0 = createBackupInfo("backup_000", 500L, BackupType.FULL,
table);
+ BackupInfo b1 = createBackupInfo("backup_001", 1000L,
BackupType.INCREMENTAL, table); // current
+ BackupInfo b2 = createBackupInfo("backup_002", 2000L,
BackupType.INCREMENTAL, table);
+ BackupInfo b3 = createBackupInfo("backup_003", 3000L,
BackupType.INCREMENTAL,
+ TableName.valueOf("other_table"));
+ BackupInfo b4 = createBackupInfo("backup_004", 4000L,
BackupType.INCREMENTAL, table);
+
+ when(mockTable.getBackupHistory("/backup/root")).thenReturn(List.of(b4,
b3, b2, b1, b0));
+
+ List<BackupInfo> result =
backupAdminImpl.getAffectedBackupSessions(current, table, mockTable);
+
+ assertEquals(2, result.size());
+ assertTrue(result.containsAll(List.of(b2, b4)));
+ }
+
+ /**
+ * Tests that a full backup for a different table is ignored and does not
reset the affected list.
+ * Only full backups for the same table act as reset boundaries.
+ */
+ @Test
+ public void testGetAffectedBackupSessions_ignoresFullBackupOfOtherTable()
throws IOException {
+ BackupInfo current = mock(BackupInfo.class);
+ TableName table = TableName.valueOf("test_table");
+
+ when(current.getStartTs()).thenReturn(1000L);
+ when(current.getBackupId()).thenReturn("backup_001");
+ when(current.getBackupRootDir()).thenReturn("/backup/root");
+
+ BackupInfo b0 = createBackupInfo("backup_000", 500L, BackupType.FULL,
table);
+ BackupInfo b1 = createBackupInfo("backup_001", 1000L,
BackupType.INCREMENTAL, table); // current
+ // Full backup for other table - should be ignored
+ BackupInfo b2 =
+ createBackupInfo("backup_002", 2000L, BackupType.FULL,
TableName.valueOf("other_table"));
+ BackupInfo b3 = createBackupInfo("backup_003", 3000L,
BackupType.INCREMENTAL, table);
+ BackupInfo b4 = createBackupInfo("backup_004", 4000L,
BackupType.INCREMENTAL, table);
+
+ when(mockTable.getBackupHistory("/backup/root")).thenReturn(List.of(b4,
b3, b2, b1, b0));
+
+ List<BackupInfo> result =
backupAdminImpl.getAffectedBackupSessions(current, table, mockTable);
+
+ // Full backup of other table should not reset, so we expect both
incremental backups after
+ // current
+ assertEquals(2, result.size());
+ assertTrue(result.containsAll(List.of(b3, b4)));
+ }
+
+ private BackupInfo createBackupInfo(String id, long ts, BackupType type,
TableName... tables) {
+ BackupInfo info = mock(BackupInfo.class);
+ when(info.getBackupId()).thenReturn(id);
+ when(info.getStartTs()).thenReturn(ts);
+ when(info.getType()).thenReturn(type);
+ List<TableName> tableList = Arrays.asList(tables);
+ when(info.getTableNames()).thenReturn(tableList);
+ when(info.getTableListAsString()).thenReturn(tableList.toString());
+ return info;
+ }
+
+ /**
+ * Tests that when a table is removed from a backup image that still
contains other tables, it
+ * updates the BackupInfo correctly and does not delete the entire backup
metadata.
+ */
+ @Test
+ public void testRemoveTableFromBackupImage() throws IOException {
+ // Arrange
+ TableName tableToRemove = TableName.valueOf("ns", "t1");
+ TableName remainingTable = TableName.valueOf("ns", "t2");
+
+ BackupInfo info = new BackupInfo();
+ info.setBackupId("backup_001");
+ info.setTables(List.of(tableToRemove, remainingTable));
+ info.setBackupRootDir("/backup/root");
+
+ BackupSystemTable sysTable = mock(BackupSystemTable.class);
+ Configuration conf = new Configuration();
+
+ Connection mockConn = mock(Connection.class);
+ when(mockConn.getConfiguration()).thenReturn(conf);
+ backupAdminImpl = spy(new BackupAdminImpl(mockConn));
+
+ doNothing().when(backupAdminImpl).cleanupBackupDir(any(), any(), any());
+
+ try (MockedStatic<BackupUtils> mockedStatic =
mockStatic(BackupUtils.class)) {
+ mockedStatic.when(() -> BackupUtils.cleanupBackupData(any(), any()))
+ .thenAnswer(invocation -> null); // no-op for safety
+
+ // Act
+ backupAdminImpl.removeTableFromBackupImage(info, tableToRemove,
sysTable);
+
+ // Assert
+ assertEquals(1, info.getTableNames().size());
+ assertTrue(info.getTableNames().contains(remainingTable));
+
+ verify(sysTable).updateBackupInfo(info);
+ verify(sysTable, never()).deleteBackupInfo(any());
+ verify(backupAdminImpl).cleanupBackupDir(eq(info), eq(tableToRemove),
eq(conf));
+
+ mockedStatic.verifyNoInteractions(); // should not call static cleanup
for partial table
+ // removal
+ }
+ }
+
+ /**
+ * Tests that when the last table in a backup image is removed, the backup
metadata is deleted
+ * entirely and static cleanup is invoked.
+ */
+ @Test
+ public void testRemoveTableFromBackupImageDeletesWhenLastTableRemoved()
throws IOException {
+ // Arrange
+ TableName onlyTable = TableName.valueOf("ns", "t1");
+
+ BackupInfo info = new BackupInfo();
+ info.setBackupId("backup_002");
+ info.setTables(new ArrayList<>(List.of(onlyTable)));
+ info.setBackupRootDir("/backup/root");
+
+ BackupSystemTable sysTable = mock(BackupSystemTable.class);
+ Configuration conf = new Configuration();
+
+ Connection mockConn = mock(Connection.class);
+ when(mockConn.getConfiguration()).thenReturn(conf);
+ backupAdminImpl = spy(new BackupAdminImpl(mockConn));
+
+ doNothing().when(backupAdminImpl).cleanupBackupDir(any(), any(), any());
+
+ try (MockedStatic<BackupUtils> mockedStatic =
mockStatic(BackupUtils.class)) {
+ mockedStatic.when(() -> BackupUtils.cleanupBackupData(any(), any()))
+ .thenAnswer(invocation -> null); // no-op for static void
+
+ // Act
+ backupAdminImpl.removeTableFromBackupImage(info, onlyTable, sysTable);
+
+ // Assert
+ verify(sysTable).deleteBackupInfo("backup_002");
+ verify(sysTable, never()).updateBackupInfo(any());
+
+ mockedStatic.verify(() -> BackupUtils.cleanupBackupData(info, conf));
+ }
+ }
+
+ /**
+ * Tests that when a backup ID is not found, the method logs a warning and
returns 0.
+ */
+ @Test
+ public void testDeleteBackupWhenBackupInfoNotFound() throws IOException {
+ String backupId = "backup_missing";
+ BackupSystemTable sysTable = mock(BackupSystemTable.class);
+ when(sysTable.readBackupInfo(backupId)).thenReturn(null);
+
+ int result = backupAdminImpl.deleteBackup(backupId, sysTable);
+
+ assertEquals(0, result);
+ verify(sysTable, never()).deleteBackupInfo(any());
+ }
+
+ /**
+ * Tests deleting a FULL backup when it is the last session for all its
tables. Ensures cleanup is
+ * called and metadata is deleted, but no other backups are affected.
+ */
+ @Test
+ public void testDeleteFullBackupWithLastSession() throws IOException {
+ TableName table = TableName.valueOf("ns", "t1");
+ String backupId = "backup_full_001";
+ BackupInfo info = new BackupInfo();
+ info.setBackupId(backupId);
+ info.setBackupRootDir("/backup/root");
+ info.setTables(List.of(table));
+ info.setStartTs(1000L);
+ info.setType(BackupType.FULL);
+
+ BackupSystemTable sysTable = mock(BackupSystemTable.class);
+ Configuration conf = new Configuration();
+ Connection mockConn = mock(Connection.class);
+ when(mockConn.getConfiguration()).thenReturn(conf);
+ backupAdminImpl = spy(new BackupAdminImpl(mockConn));
+
+ when(sysTable.readBackupInfo(backupId)).thenReturn(info);
+ when(backupAdminImpl.isLastBackupSession(sysTable, table,
1000L)).thenReturn(true);
+ when(sysTable.readBulkLoadedFiles(backupId)).thenReturn(Map.of());
+
+ doNothing().when(sysTable).deleteBackupInfo(backupId);
+
+ try (MockedStatic<BackupUtils> mockedStatic =
mockStatic(BackupUtils.class)) {
+ mockedStatic.when(() -> BackupUtils.cleanupBackupData(eq(info),
eq(conf)))
+ .thenAnswer(inv -> null);
+
+ int result = backupAdminImpl.deleteBackup(backupId, sysTable);
+
+ assertEquals(1, result);
+ verify(sysTable).deleteBackupInfo(backupId);
+ mockedStatic.verify(() -> BackupUtils.cleanupBackupData(eq(info),
eq(conf)));
+ }
+ }
+
+ /**
+ * Tests that deleteBackup will update other backups by removing the table
when it's not the last
+ * session.
+ */
+ @Test
+ public void testDeleteBackupWithAffectedSessions() throws IOException {
+ TableName table = TableName.valueOf("ns", "t1");
+ String backupId = "backup_inc_001";
+ BackupInfo current = new BackupInfo();
+ current.setBackupId(backupId);
+ current.setBackupRootDir("/backup/root");
+ current.setTables(List.of(table));
+ current.setStartTs(2000L);
+ current.setType(BackupType.INCREMENTAL);
+
+ BackupInfo dependent = new BackupInfo();
+ dependent.setBackupId("backup_inc_002");
+ dependent.setBackupRootDir("/backup/root");
+ dependent.setTables(new ArrayList<>(List.of(table)));
+ dependent.setStartTs(3000L);
+ dependent.setType(BackupType.INCREMENTAL);
+
+ BackupSystemTable sysTable = mock(BackupSystemTable.class);
+ Configuration conf = new Configuration();
+ Connection mockConn = mock(Connection.class);
+ when(mockConn.getConfiguration()).thenReturn(conf);
+ backupAdminImpl = spy(new BackupAdminImpl(mockConn));
+
+ when(sysTable.readBackupInfo(backupId)).thenReturn(current);
+ when(sysTable.readBulkLoadedFiles(backupId)).thenReturn(Map.of());
+ when(backupAdminImpl.isLastBackupSession(sysTable, table,
2000L)).thenReturn(false);
+
doReturn(List.of(dependent)).when(backupAdminImpl).getAffectedBackupSessions(current,
table,
+ sysTable);
+
+
doNothing().when(backupAdminImpl).removeTableFromBackupImage(eq(dependent),
eq(table),
+ eq(sysTable));
+
+ try (MockedStatic<BackupUtils> mockedStatic =
mockStatic(BackupUtils.class)) {
+ mockedStatic.when(() -> BackupUtils.cleanupBackupData(eq(current),
eq(conf)))
+ .thenAnswer(inv -> null);
+
+ int result = backupAdminImpl.deleteBackup(backupId, sysTable);
+
+ assertEquals(1, result);
+ verify(backupAdminImpl).removeTableFromBackupImage(dependent, table,
sysTable);
+ verify(sysTable).deleteBackupInfo(backupId);
+ mockedStatic.verify(() -> BackupUtils.cleanupBackupData(current, conf));
+ }
+ }
+
+ /**
+ * Tests that deleteBackup will remove bulk-loaded files and handle
exceptions gracefully.
+ */
+ @Test
+ public void testDeleteBackupWithBulkLoadedFiles() throws IOException {
+ // Set up test data
+ TableName table = TableName.valueOf("ns", "t1");
+ String backupId = "backup_with_bulkload";
+ Path dummyPath = new Path("/bulk/load/file1");
+ Map<byte[], String> bulkFiles = Map.of("k1".getBytes(),
dummyPath.toString());
+
+ // BackupInfo mock
+ BackupInfo info = new BackupInfo();
+ info.setBackupId(backupId);
+ info.setBackupRootDir("/backup/root");
+ info.setTables(List.of(table));
+ info.setStartTs(1500L);
+ info.setType(BackupType.FULL);
+
+ // Create mock objects
+ Configuration conf = new Configuration();
+ Connection conn = mock(Connection.class);
+ when(conn.getConfiguration()).thenReturn(conf);
+
+ BackupSystemTable sysTable = mock(BackupSystemTable.class);
+ when(sysTable.readBackupInfo(backupId)).thenReturn(info);
+ when(sysTable.readBulkLoadedFiles(backupId)).thenReturn(bulkFiles);
+
+ FileSystem fs = mock(FileSystem.class);
+
+ try (MockedStatic<FileSystem> fsStatic = mockStatic(FileSystem.class)) {
+ fsStatic.when(() -> FileSystem.get(conf)).thenReturn(fs);
+ when(fs.delete(dummyPath)).thenReturn(true); // Simulate successful
delete
+
+ // Create spy on BackupAdminImpl
+ BackupAdminImpl backupAdmin = spy(new BackupAdminImpl(conn));
+ when(backupAdmin.isLastBackupSession(sysTable, table,
1500L)).thenReturn(true);
+
+ // No-ops for cleanup
+ doNothing().when(sysTable).deleteBackupInfo(backupId);
+ doNothing().when(sysTable).deleteBulkLoadedRows(any());
+
+ try (MockedStatic<BackupUtils> staticMock =
mockStatic(BackupUtils.class)) {
+ staticMock.when(() -> BackupUtils.cleanupBackupData(info, conf))
+ .thenAnswer(invocation -> null);
+
+ // Execute method
+ int result = backupAdmin.deleteBackup(backupId, sysTable);
+
+ // Assertions
+ assertEquals(1, result);
+ verify(fs).delete(dummyPath);
+ verify(sysTable).deleteBulkLoadedRows(any());
+ verify(sysTable).deleteBackupInfo(backupId);
+ staticMock.verify(() -> BackupUtils.cleanupBackupData(info, conf),
times(1));
+ }
+ }
+ }
+
+ /**
+ * Verifies that checkIfValidForMerge succeeds with valid INCREMENTAL,
COMPLETE images from the
+ * same destination and no holes in the backup sequence.
+ */
+ @Test
+ public void testCheckIfValidForMerge_validCase() throws IOException {
+ String[] ids = { "b1", "b2" };
+ TableName t1 = TableName.valueOf("ns", "t1");
+ String dest = "/backup/root";
+
+ BackupInfo b1 =
+ createBackupInfo("b1", BackupType.INCREMENTAL, BackupState.COMPLETE,
1000L, dest, t1);
+ BackupInfo b2 =
+ createBackupInfo("b2", BackupType.INCREMENTAL, BackupState.COMPLETE,
2000L, dest, t1);
+
+ BackupSystemTable table = mock(BackupSystemTable.class);
+ when(table.readBackupInfo("b1")).thenReturn(b1);
+ when(table.readBackupInfo("b2")).thenReturn(b2);
+ when(table.getBackupHistory(eq(-1), any(), any(), any(), any(), any()))
+ .thenReturn(List.of(b1, b2));
+
+ new BackupAdminImpl(mock(Connection.class)).checkIfValidForMerge(ids,
table);
+ }
+
+ /**
+ * Verifies that checkIfValidForMerge fails if a FULL backup is included.
+ */
+ @Test(expected = IOException.class)
+ public void testCheckIfValidForMerge_failsWithFullBackup() throws
IOException {
+ String[] ids = { "b1" };
+ TableName t1 = TableName.valueOf("ns", "t1");
+
+ BackupInfo b1 =
+ createBackupInfo("b1", BackupType.FULL, BackupState.COMPLETE, 1000L,
"/dest", t1);
+
+ BackupSystemTable table = mock(BackupSystemTable.class);
+ when(table.readBackupInfo("b1")).thenReturn(b1);
+
+ new BackupAdminImpl(mock(Connection.class)).checkIfValidForMerge(ids,
table);
+ }
+
+ /**
+ * Verifies that checkIfValidForMerge fails if one of the provided backup
IDs is not found in the
+ * system table (i.e., null is returned).
+ */
+ @Test(expected = IOException.class)
+ public void testCheckIfValidForMerge_failsWhenBackupInfoNotFound() throws
IOException {
+ String[] ids = { "b_missing" };
+
+ BackupSystemTable table = mock(BackupSystemTable.class);
+ when(table.readBackupInfo("b_missing")).thenReturn(null);
+
+ new BackupAdminImpl(mock(Connection.class)).checkIfValidForMerge(ids,
table);
+ }
+
+ /**
+ * Verifies that checkIfValidForMerge fails when backups come from different
destinations.
+ */
+ @Test(expected = IOException.class)
+ public void testCheckIfValidForMerge_failsWithDifferentDestinations() throws
IOException {
+ String[] ids = { "b1", "b2" };
+ TableName t1 = TableName.valueOf("ns", "t1");
+
+ BackupInfo b1 =
+ createBackupInfo("b1", BackupType.INCREMENTAL, BackupState.COMPLETE,
1000L, "/dest1", t1);
+ BackupInfo b2 =
+ createBackupInfo("b2", BackupType.INCREMENTAL, BackupState.COMPLETE,
2000L, "/dest2", t1);
+
+ BackupSystemTable table = mock(BackupSystemTable.class);
+ when(table.readBackupInfo("b1")).thenReturn(b1);
+ when(table.readBackupInfo("b2")).thenReturn(b2);
+
+ new BackupAdminImpl(mock(Connection.class)).checkIfValidForMerge(ids,
table);
+ }
+
+ /**
+ * Verifies that checkIfValidForMerge fails if any backup is not in COMPLETE
state.
+ */
+ @Test(expected = IOException.class)
+ public void testCheckIfValidForMerge_failsWithNonCompleteState() throws
IOException {
+ String[] ids = { "b1" };
+ TableName t1 = TableName.valueOf("ns", "t1");
+
+ BackupInfo b1 =
+ createBackupInfo("b1", BackupType.INCREMENTAL, BackupState.RUNNING,
1000L, "/dest", t1);
+
+ BackupSystemTable table = mock(BackupSystemTable.class);
+ when(table.readBackupInfo("b1")).thenReturn(b1);
+
+ new BackupAdminImpl(mock(Connection.class)).checkIfValidForMerge(ids,
table);
+ }
+
+ /**
+ * Verifies that checkIfValidForMerge fails when there is a "hole" in the
backup sequence — i.e.,
+ * a required image from the full backup history is missing in the input
list.
+ */
+ @Test(expected = IOException.class)
+ public void testCheckIfValidForMerge_failsWhenHoleInImages() throws
IOException {
+ TableName t1 = TableName.valueOf("ns", "t1");
+ String dest = "/backup/root";
+
+ BackupInfo b1 =
+ createBackupInfo("b1", BackupType.INCREMENTAL, BackupState.COMPLETE,
1000L, dest, t1);
+ BackupInfo b2 =
+ createBackupInfo("b2", BackupType.INCREMENTAL, BackupState.COMPLETE,
2000L, dest, t1);
+ BackupInfo b3 =
+ createBackupInfo("b3", BackupType.INCREMENTAL, BackupState.COMPLETE,
3000L, dest, t1);
+
+ BackupSystemTable table = mock(BackupSystemTable.class);
+ when(table.readBackupInfo("b1")).thenReturn(b1);
+ when(table.readBackupInfo("b2")).thenReturn(b2);
+ when(table.readBackupInfo("b3")).thenReturn(b3);
+
+ when(table.getBackupHistory(eq(-1), any(), any(), any(), any(), any()))
+ .thenReturn(List.of(b1, b2, b3));
+
+ // Simulate a "hole" by omitting b2 from images
+ String[] idsWithHole = { "b1", "b3" };
+ new
BackupAdminImpl(mock(Connection.class)).checkIfValidForMerge(idsWithHole,
table);
+ }
+
+ private BackupInfo createBackupInfo(String id, BackupType type,
BackupInfo.BackupState state,
+ long ts, String dest, TableName... tables) {
+ BackupInfo info = new BackupInfo();
+ info.setBackupId(id);
+ info.setType(type);
+ info.setState(state);
+ info.setStartTs(ts);
+ info.setBackupRootDir(dest);
+ info.setTables(List.of(tables));
+ return info;
+ }
+}