001/* 002 * Licensed to the Apache Software Foundation (ASF) under one 003 * or more contributor license agreements. See the NOTICE file 004 * distributed with this work for additional information 005 * regarding copyright ownership. The ASF licenses this file 006 * to you under the Apache License, Version 2.0 (the 007 * "License"); you may not use this file except in compliance 008 * with the License. You may obtain a copy of the License at 009 * 010 * http://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, software 013 * distributed under the License is distributed on an "AS IS" BASIS, 014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 015 * See the License for the specific language governing permissions and 016 * limitations under the License. 017 */ 018package org.apache.hadoop.hbase.backup.impl; 019 020import static org.junit.jupiter.api.Assertions.assertEquals; 021import static org.junit.jupiter.api.Assertions.assertThrows; 022import static org.junit.jupiter.api.Assertions.assertTrue; 023import static org.mockito.ArgumentMatchers.any; 024import static org.mockito.ArgumentMatchers.anySet; 025import static org.mockito.ArgumentMatchers.eq; 026import static org.mockito.Mockito.doNothing; 027import static org.mockito.Mockito.doReturn; 028import static org.mockito.Mockito.doThrow; 029import static org.mockito.Mockito.mock; 030import static org.mockito.Mockito.mockStatic; 031import static org.mockito.Mockito.never; 032import static org.mockito.Mockito.spy; 033import static org.mockito.Mockito.times; 034import static org.mockito.Mockito.verify; 035import static org.mockito.Mockito.when; 036 037import java.io.IOException; 038import java.util.ArrayList; 039import java.util.Arrays; 040import java.util.Collections; 041import java.util.HashMap; 042import java.util.HashSet; 043import java.util.List; 044import java.util.Map; 045import java.util.Set; 046import org.apache.hadoop.conf.Configuration; 047import org.apache.hadoop.fs.FileSystem; 048import org.apache.hadoop.fs.Path; 049import org.apache.hadoop.hbase.TableName; 050import org.apache.hadoop.hbase.backup.BackupInfo; 051import org.apache.hadoop.hbase.backup.BackupInfo.BackupState; 052import org.apache.hadoop.hbase.backup.BackupType; 053import org.apache.hadoop.hbase.backup.util.BackupUtils; 054import org.apache.hadoop.hbase.client.Connection; 055import org.apache.hadoop.hbase.testclassification.SmallTests; 056import org.junit.jupiter.api.BeforeEach; 057import org.junit.jupiter.api.Tag; 058import org.junit.jupiter.api.Test; 059import org.mockito.ArgumentCaptor; 060import org.mockito.MockedStatic; 061 062/** 063 * Unit tests for {@link BackupAdminImpl}. 064 * <p> 065 * This class improves test coverage by validating the behavior of key methods in BackupAdminImpl. 066 * Some methods are made package-private to enable testing. 067 */ 068@Tag(SmallTests.TAG) 069public class TestBackupAdminImpl { 070 private BackupAdminImpl backupAdminImpl; 071 private BackupSystemTable mockTable; 072 073 @BeforeEach 074 public void setUp() { 075 backupAdminImpl = new BackupAdminImpl(null); 076 mockTable = mock(BackupSystemTable.class); 077 } 078 079 /** 080 * Scenario: - The initial incremental table set contains "table1" and "table2" - Only "table1" 081 * still exists in backup history Expectation: - The backup system should delete the existing set 082 * - Then re-add a filtered set that includes only "table1" 083 */ 084 @Test 085 public void testFinalizeDelete_addsRetainedTablesBack() throws IOException { 086 String backupRoot = "backupRoot1"; 087 List<String> backupRoots = Collections.singletonList(backupRoot); 088 089 Set<TableName> initialTableSet = 090 new HashSet<>(Arrays.asList(TableName.valueOf("ns:table1"), TableName.valueOf("ns:table2"))); 091 092 Map<TableName, List<BackupInfo>> backupHistory = new HashMap<>(); 093 backupHistory.put(TableName.valueOf("ns:table1"), List.of(new BackupInfo())); // Only table1 094 // retained 095 096 when(mockTable.getIncrementalBackupTableSet(backupRoot)) 097 .thenReturn(new HashSet<>(initialTableSet)); 098 when(mockTable.getBackupHistoryForTableSet(initialTableSet, backupRoot)) 099 .thenReturn(backupHistory); 100 101 backupAdminImpl.finalizeDelete(backupRoots, mockTable); 102 103 // Always remove existing backup metadata 104 verify(mockTable).deleteIncrementalBackupTableSet(backupRoot); 105 106 // Re-add only retained tables (should be just table1) 107 @SuppressWarnings("unchecked") 108 ArgumentCaptor<Set<TableName>> captor = 109 (ArgumentCaptor<Set<TableName>>) (ArgumentCaptor<?>) ArgumentCaptor.forClass(Set.class); 110 verify(mockTable).addIncrementalBackupTableSet(captor.capture(), eq(backupRoot)); 111 112 Set<TableName> retained = captor.getValue(); 113 assertEquals(1, retained.size()); 114 assertTrue(retained.contains(TableName.valueOf("ns:table1"))); 115 } 116 117 /** 118 * Scenario: - The incremental table set has "tableX" - No backups exist for this table 119 * Expectation: - Backup metadata should be deleted - Nothing should be re-added since no tables 120 * are retained 121 */ 122 @Test 123 public void testFinalizeDelete_retainedSetEmpty_doesNotAddBack() throws IOException { 124 String backupRoot = "backupRoot2"; 125 List<String> backupRoots = List.of(backupRoot); 126 127 Set<TableName> initialTableSet = Set.of(TableName.valueOf("ns:tableX")); 128 Map<TableName, List<BackupInfo>> backupHistory = Map.of(); // No overlap 129 130 when(mockTable.getIncrementalBackupTableSet(backupRoot)) 131 .thenReturn(new HashSet<>(initialTableSet)); 132 when(mockTable.getBackupHistoryForTableSet(initialTableSet, backupRoot)) 133 .thenReturn(backupHistory); 134 135 backupAdminImpl.finalizeDelete(backupRoots, mockTable); 136 137 // Delete should be called 138 verify(mockTable).deleteIncrementalBackupTableSet(backupRoot); 139 // No add back since retained set is empty 140 verify(mockTable, never()).addIncrementalBackupTableSet(any(), eq(backupRoot)); 141 } 142 143 /** 144 * Scenario: - Two backup roots: - root1: one table with valid backup history → should be retained 145 * - root2: one table with no history → should not be retained Expectation: - root1 metadata 146 * deleted and re-added - root2 metadata only deleted 147 */ 148 @Test 149 public void testFinalizeDelete_multipleRoots() throws IOException { 150 String root1 = "root1"; 151 String root2 = "root2"; 152 List<String> roots = List.of(root1, root2); 153 154 TableName t1 = TableName.valueOf("ns:table1"); 155 TableName t2 = TableName.valueOf("ns:table2"); 156 157 // root1 setup 158 when(mockTable.getIncrementalBackupTableSet(root1)).thenReturn(new HashSet<>(List.of(t1))); 159 when(mockTable.getBackupHistoryForTableSet(Set.of(t1), root1)) 160 .thenReturn(Map.of(t1, List.of(new BackupInfo()))); 161 162 // root2 setup 163 when(mockTable.getIncrementalBackupTableSet(root2)).thenReturn(new HashSet<>(List.of(t2))); 164 when(mockTable.getBackupHistoryForTableSet(Set.of(t2), root2)).thenReturn(Map.of()); // empty 165 // history 166 167 backupAdminImpl.finalizeDelete(roots, mockTable); 168 169 // root1: should delete and re-add table 170 verify(mockTable).deleteIncrementalBackupTableSet(root1); 171 verify(mockTable).addIncrementalBackupTableSet(Set.of(t1), root1); 172 173 // root2: delete only 174 verify(mockTable).deleteIncrementalBackupTableSet(root2); 175 verify(mockTable, never()).addIncrementalBackupTableSet(anySet(), eq(root2)); 176 } 177 178 /** 179 * Verifies that {@code cleanupBackupDir} correctly deletes the target backup directory for a 180 * given table and backup ID using the mocked FileSystem. 181 * <p> 182 * This test ensures: - The correct path is constructed using BackupUtils. - FileSystem#delete is 183 * invoked with that path. 184 */ 185 @Test 186 public void testCleanupBackupDir_deletesTargetDirSuccessfully() throws Exception { 187 // Setup test input 188 String backupId = "backup_001"; 189 String backupRootDir = "/backup/root"; 190 TableName table = TableName.valueOf("test_table"); 191 192 BackupInfo mockBackupInfo = mock(BackupInfo.class); 193 when(mockBackupInfo.getBackupRootDir()).thenReturn(backupRootDir); 194 when(mockBackupInfo.getBackupId()).thenReturn(backupId); 195 196 Configuration conf = new Configuration(); 197 198 // Spy BackupAdminImpl to mock getFileSystem behavior 199 backupAdminImpl = spy(backupAdminImpl); 200 201 FileSystem mockFs = mock(FileSystem.class); 202 Path expectedPath = new Path(BackupUtils.getTableBackupDir(backupRootDir, backupId, table)); 203 204 // Mock getFileSystem to return our mock FileSystem 205 doReturn(mockFs).when(backupAdminImpl).getFileSystem(any(Path.class), eq(conf)); 206 when(mockFs.delete(expectedPath, true)).thenReturn(true); 207 208 // Call the method under test 209 backupAdminImpl.cleanupBackupDir(mockBackupInfo, table, conf); 210 211 // Verify the FileSystem delete call with correct path 212 verify(mockFs).delete(expectedPath, true); 213 } 214 215 /** 216 * Verifies that {@code cleanupBackupDir} throws an {@link IOException} if the FileSystem 217 * retrieval fails. 218 * <p> 219 * This test simulates an exception while trying to obtain the FileSystem, and expects the method 220 * to propagate the exception. 221 */ 222 @Test 223 public void testCleanupBackupDir_throwsIOException() throws Exception { 224 // Setup test input 225 String backupId = "backup_003"; 226 String backupRootDir = "/backup/root"; 227 TableName table = TableName.valueOf("test_table"); 228 229 BackupInfo mockBackupInfo = mock(BackupInfo.class); 230 when(mockBackupInfo.getBackupRootDir()).thenReturn(backupRootDir); 231 when(mockBackupInfo.getBackupId()).thenReturn(backupId); 232 233 Configuration conf = new Configuration(); 234 235 // Spy BackupAdminImpl to inject failure in getFileSystem 236 backupAdminImpl = spy(backupAdminImpl); 237 doThrow(new IOException("FS error")).when(backupAdminImpl).getFileSystem(any(Path.class), 238 eq(conf)); 239 240 // Call method and expect IOException 241 assertThrows(IOException.class, 242 () -> backupAdminImpl.cleanupBackupDir(mockBackupInfo, table, conf)); 243 } 244 245 /** 246 * Tests that when a current incremental backup is found in the history, all later incremental 247 * backups for the same table are returned. This simulates rolling forward from the current backup 248 * timestamp, capturing newer incremental backups that depend on it. 249 */ 250 @Test 251 public void testGetAffectedBackupSessions() throws IOException { 252 BackupInfo current = mock(BackupInfo.class); 253 TableName table = TableName.valueOf("test_table"); 254 255 when(current.getStartTs()).thenReturn(2000L); 256 when(current.getBackupId()).thenReturn("backup_002"); 257 when(current.getBackupRootDir()).thenReturn("/backup/root"); 258 259 BackupInfo b0 = createBackupInfo("backup_000", 500L, BackupType.FULL, table); 260 BackupInfo b1 = createBackupInfo("backup_001", 1000L, BackupType.INCREMENTAL, table); 261 BackupInfo b2 = createBackupInfo("backup_002", 2000L, BackupType.INCREMENTAL, table); // current 262 BackupInfo b3 = createBackupInfo("backup_003", 3000L, BackupType.INCREMENTAL, table); 263 BackupInfo b4 = createBackupInfo("backup_004", 4000L, BackupType.INCREMENTAL, table); 264 265 when(mockTable.getBackupHistory(any())).thenReturn(List.of(b4, b3, b2, b1, b0)); 266 267 List<BackupInfo> result = backupAdminImpl.getAffectedBackupSessions(current, table, mockTable); 268 269 assertEquals(2, result.size()); 270 assertTrue(result.containsAll(List.of(b3, b4))); 271 } 272 273 /** 274 * Tests that if a full backup appears after the current backup, the affected list is reset and 275 * incremental backups following that full backup are not included. This ensures full backups act 276 * as a reset boundary. 277 */ 278 @Test 279 public void testGetAffectedBackupSessions_resetsOnFullBackup() throws IOException { 280 BackupInfo current = mock(BackupInfo.class); 281 TableName table = TableName.valueOf("test_table"); 282 283 when(current.getStartTs()).thenReturn(1000L); 284 when(current.getBackupId()).thenReturn("backup_001"); 285 when(current.getBackupRootDir()).thenReturn("/backup/root"); 286 287 BackupInfo b0 = createBackupInfo("backup_000", 500L, BackupType.FULL, table); 288 BackupInfo b1 = createBackupInfo("backup_001", 1000L, BackupType.INCREMENTAL, table); // current 289 BackupInfo b2 = createBackupInfo("backup_002", 2000L, BackupType.FULL, table); 290 BackupInfo b3 = createBackupInfo("backup_003", 3000L, BackupType.INCREMENTAL, table); 291 292 when(mockTable.getBackupHistory(any())).thenReturn(List.of(b3, b2, b1, b0)); 293 294 List<BackupInfo> result = backupAdminImpl.getAffectedBackupSessions(current, table, mockTable); 295 296 assertTrue(result.isEmpty()); 297 } 298 299 /** 300 * Tests that backups for other tables are ignored, even if they are incremental and fall after 301 * the current backup. Only backups affecting the specified table should be considered. 302 */ 303 @Test 304 public void testGetAffectedBackupSessions_skipsNonMatchingTable() throws IOException { 305 BackupInfo current = mock(BackupInfo.class); 306 TableName table = TableName.valueOf("test_table"); 307 308 when(current.getStartTs()).thenReturn(1000L); 309 when(current.getBackupId()).thenReturn("backup_001"); 310 when(current.getBackupRootDir()).thenReturn("/backup/root"); 311 312 BackupInfo b0 = createBackupInfo("backup_000", 500L, BackupType.FULL, table); 313 BackupInfo b1 = createBackupInfo("backup_001", 1000L, BackupType.INCREMENTAL, table); // current 314 BackupInfo b2 = createBackupInfo("backup_002", 2000L, BackupType.INCREMENTAL, table); 315 BackupInfo b3 = createBackupInfo("backup_003", 3000L, BackupType.INCREMENTAL, 316 TableName.valueOf("other_table")); 317 BackupInfo b4 = createBackupInfo("backup_004", 4000L, BackupType.INCREMENTAL, table); 318 319 when(mockTable.getBackupHistory(any())).thenReturn(List.of(b4, b3, b2, b1, b0)); 320 321 List<BackupInfo> result = backupAdminImpl.getAffectedBackupSessions(current, table, mockTable); 322 323 assertEquals(2, result.size()); 324 assertTrue(result.containsAll(List.of(b2, b4))); 325 } 326 327 /** 328 * Tests that a full backup for a different table is ignored and does not reset the affected list. 329 * Only full backups for the same table act as reset boundaries. 330 */ 331 @Test 332 public void testGetAffectedBackupSessions_ignoresFullBackupOfOtherTable() throws IOException { 333 BackupInfo current = mock(BackupInfo.class); 334 TableName table = TableName.valueOf("test_table"); 335 336 when(current.getStartTs()).thenReturn(1000L); 337 when(current.getBackupId()).thenReturn("backup_001"); 338 when(current.getBackupRootDir()).thenReturn("/backup/root"); 339 340 BackupInfo b0 = createBackupInfo("backup_000", 500L, BackupType.FULL, table); 341 BackupInfo b1 = createBackupInfo("backup_001", 1000L, BackupType.INCREMENTAL, table); // current 342 // Full backup for other table - should be ignored 343 BackupInfo b2 = 344 createBackupInfo("backup_002", 2000L, BackupType.FULL, TableName.valueOf("other_table")); 345 BackupInfo b3 = createBackupInfo("backup_003", 3000L, BackupType.INCREMENTAL, table); 346 BackupInfo b4 = createBackupInfo("backup_004", 4000L, BackupType.INCREMENTAL, table); 347 348 when(mockTable.getBackupHistory(any())).thenReturn(List.of(b4, b3, b2, b1, b0)); 349 350 List<BackupInfo> result = backupAdminImpl.getAffectedBackupSessions(current, table, mockTable); 351 352 // Full backup of other table should not reset, so we expect both incremental backups after 353 // current 354 assertEquals(2, result.size()); 355 assertTrue(result.containsAll(List.of(b3, b4))); 356 } 357 358 private BackupInfo createBackupInfo(String id, long ts, BackupType type, TableName... tables) { 359 BackupInfo info = mock(BackupInfo.class); 360 when(info.getBackupId()).thenReturn(id); 361 when(info.getStartTs()).thenReturn(ts); 362 when(info.getType()).thenReturn(type); 363 List<TableName> tableList = Arrays.asList(tables); 364 when(info.getTableNames()).thenReturn(tableList); 365 when(info.getTableListAsString()).thenReturn(tableList.toString()); 366 return info; 367 } 368 369 /** 370 * Tests that when a table is removed from a backup image that still contains other tables, it 371 * updates the BackupInfo correctly and does not delete the entire backup metadata. 372 */ 373 @Test 374 public void testRemoveTableFromBackupImage() throws IOException { 375 // Arrange 376 TableName tableToRemove = TableName.valueOf("ns", "t1"); 377 TableName remainingTable = TableName.valueOf("ns", "t2"); 378 379 BackupInfo info = new BackupInfo(); 380 info.setBackupId("backup_001"); 381 info.setTables(List.of(tableToRemove, remainingTable)); 382 info.setBackupRootDir("/backup/root"); 383 384 BackupSystemTable sysTable = mock(BackupSystemTable.class); 385 Configuration conf = new Configuration(); 386 387 Connection mockConn = mock(Connection.class); 388 when(mockConn.getConfiguration()).thenReturn(conf); 389 backupAdminImpl = spy(new BackupAdminImpl(mockConn)); 390 391 doNothing().when(backupAdminImpl).cleanupBackupDir(any(), any(), any()); 392 393 try (MockedStatic<BackupUtils> mockedStatic = mockStatic(BackupUtils.class)) { 394 mockedStatic.when(() -> BackupUtils.cleanupBackupData(any(), any())) 395 .thenAnswer(invocation -> null); // no-op for safety 396 397 // Act 398 backupAdminImpl.removeTableFromBackupImage(info, tableToRemove, sysTable); 399 400 // Assert 401 assertEquals(1, info.getTableNames().size()); 402 assertTrue(info.getTableNames().contains(remainingTable)); 403 404 verify(sysTable).updateBackupInfo(info); 405 verify(sysTable, never()).deleteBackupInfo(any()); 406 verify(backupAdminImpl).cleanupBackupDir(eq(info), eq(tableToRemove), eq(conf)); 407 408 mockedStatic.verifyNoInteractions(); // should not call static cleanup for partial table 409 // removal 410 } 411 } 412 413 /** 414 * Tests that when the last table in a backup image is removed, the backup metadata is deleted 415 * entirely and static cleanup is invoked. 416 */ 417 @Test 418 public void testRemoveTableFromBackupImageDeletesWhenLastTableRemoved() throws IOException { 419 // Arrange 420 TableName onlyTable = TableName.valueOf("ns", "t1"); 421 422 BackupInfo info = new BackupInfo(); 423 info.setBackupId("backup_002"); 424 info.setTables(new ArrayList<>(List.of(onlyTable))); 425 info.setBackupRootDir("/backup/root"); 426 427 BackupSystemTable sysTable = mock(BackupSystemTable.class); 428 Configuration conf = new Configuration(); 429 430 Connection mockConn = mock(Connection.class); 431 when(mockConn.getConfiguration()).thenReturn(conf); 432 backupAdminImpl = spy(new BackupAdminImpl(mockConn)); 433 434 doNothing().when(backupAdminImpl).cleanupBackupDir(any(), any(), any()); 435 436 try (MockedStatic<BackupUtils> mockedStatic = mockStatic(BackupUtils.class)) { 437 mockedStatic.when(() -> BackupUtils.cleanupBackupData(any(), any())) 438 .thenAnswer(invocation -> null); // no-op for static void 439 440 // Act 441 backupAdminImpl.removeTableFromBackupImage(info, onlyTable, sysTable); 442 443 // Assert 444 verify(sysTable).deleteBackupInfo("backup_002"); 445 verify(sysTable, never()).updateBackupInfo(any()); 446 447 mockedStatic.verify(() -> BackupUtils.cleanupBackupData(info, conf)); 448 } 449 } 450 451 /** 452 * Tests that when a backup ID is not found, the method logs a warning and returns 0. 453 */ 454 @Test 455 public void testDeleteBackupWhenBackupInfoNotFound() throws IOException { 456 String backupId = "backup_missing"; 457 BackupSystemTable sysTable = mock(BackupSystemTable.class); 458 when(sysTable.readBackupInfo(backupId)).thenReturn(null); 459 460 int result = backupAdminImpl.deleteBackup(backupId, sysTable); 461 462 assertEquals(0, result); 463 verify(sysTable, never()).deleteBackupInfo(any()); 464 } 465 466 /** 467 * Tests deleting a FULL backup when it is the last session for all its tables. Ensures cleanup is 468 * called and metadata is deleted, but no other backups are affected. 469 */ 470 @Test 471 public void testDeleteFullBackupWithLastSession() throws IOException { 472 TableName table = TableName.valueOf("ns", "t1"); 473 String backupId = "backup_full_001"; 474 BackupInfo info = new BackupInfo(); 475 info.setBackupId(backupId); 476 info.setBackupRootDir("/backup/root"); 477 info.setTables(List.of(table)); 478 info.setStartTs(1000L); 479 info.setType(BackupType.FULL); 480 481 BackupSystemTable sysTable = mock(BackupSystemTable.class); 482 Configuration conf = new Configuration(); 483 Connection mockConn = mock(Connection.class); 484 when(mockConn.getConfiguration()).thenReturn(conf); 485 backupAdminImpl = spy(new BackupAdminImpl(mockConn)); 486 487 when(sysTable.readBackupInfo(backupId)).thenReturn(info); 488 when(backupAdminImpl.isLastBackupSession(sysTable, table, 1000L)).thenReturn(true); 489 when(sysTable.readBulkLoadedFiles(backupId)).thenReturn(Map.of()); 490 491 doNothing().when(sysTable).deleteBackupInfo(backupId); 492 493 try (MockedStatic<BackupUtils> mockedStatic = mockStatic(BackupUtils.class)) { 494 mockedStatic.when(() -> BackupUtils.cleanupBackupData(eq(info), eq(conf))) 495 .thenAnswer(inv -> null); 496 497 int result = backupAdminImpl.deleteBackup(backupId, sysTable); 498 499 assertEquals(1, result); 500 verify(sysTable).deleteBackupInfo(backupId); 501 mockedStatic.verify(() -> BackupUtils.cleanupBackupData(eq(info), eq(conf))); 502 } 503 } 504 505 /** 506 * Tests that deleteBackup will update other backups by removing the table when it's not the last 507 * session. 508 */ 509 @Test 510 public void testDeleteBackupWithAffectedSessions() throws IOException { 511 TableName table = TableName.valueOf("ns", "t1"); 512 String backupId = "backup_inc_001"; 513 BackupInfo current = new BackupInfo(); 514 current.setBackupId(backupId); 515 current.setBackupRootDir("/backup/root"); 516 current.setTables(List.of(table)); 517 current.setStartTs(2000L); 518 current.setType(BackupType.INCREMENTAL); 519 520 BackupInfo dependent = new BackupInfo(); 521 dependent.setBackupId("backup_inc_002"); 522 dependent.setBackupRootDir("/backup/root"); 523 dependent.setTables(new ArrayList<>(List.of(table))); 524 dependent.setStartTs(3000L); 525 dependent.setType(BackupType.INCREMENTAL); 526 527 BackupSystemTable sysTable = mock(BackupSystemTable.class); 528 Configuration conf = new Configuration(); 529 Connection mockConn = mock(Connection.class); 530 when(mockConn.getConfiguration()).thenReturn(conf); 531 backupAdminImpl = spy(new BackupAdminImpl(mockConn)); 532 533 when(sysTable.readBackupInfo(backupId)).thenReturn(current); 534 when(sysTable.readBulkLoadedFiles(backupId)).thenReturn(Map.of()); 535 when(backupAdminImpl.isLastBackupSession(sysTable, table, 2000L)).thenReturn(false); 536 doReturn(List.of(dependent)).when(backupAdminImpl).getAffectedBackupSessions(current, table, 537 sysTable); 538 539 doNothing().when(backupAdminImpl).removeTableFromBackupImage(eq(dependent), eq(table), 540 eq(sysTable)); 541 542 try (MockedStatic<BackupUtils> mockedStatic = mockStatic(BackupUtils.class)) { 543 mockedStatic.when(() -> BackupUtils.cleanupBackupData(eq(current), eq(conf))) 544 .thenAnswer(inv -> null); 545 546 int result = backupAdminImpl.deleteBackup(backupId, sysTable); 547 548 assertEquals(1, result); 549 verify(backupAdminImpl).removeTableFromBackupImage(dependent, table, sysTable); 550 verify(sysTable).deleteBackupInfo(backupId); 551 mockedStatic.verify(() -> BackupUtils.cleanupBackupData(current, conf)); 552 } 553 } 554 555 /** 556 * Tests that deleteBackup will remove bulk-loaded files and handle exceptions gracefully. 557 */ 558 @Test 559 public void testDeleteBackupWithBulkLoadedFiles() throws IOException { 560 // Set up test data 561 TableName table = TableName.valueOf("ns", "t1"); 562 String backupId = "backup_with_bulkload"; 563 Path dummyPath = new Path("/bulk/load/file1"); 564 Map<byte[], String> bulkFiles = Map.of("k1".getBytes(), dummyPath.toString()); 565 566 // BackupInfo mock 567 BackupInfo info = new BackupInfo(); 568 info.setBackupId(backupId); 569 info.setBackupRootDir("/backup/root"); 570 info.setTables(List.of(table)); 571 info.setStartTs(1500L); 572 info.setType(BackupType.FULL); 573 574 // Create mock objects 575 Configuration conf = new Configuration(); 576 Connection conn = mock(Connection.class); 577 when(conn.getConfiguration()).thenReturn(conf); 578 579 BackupSystemTable sysTable = mock(BackupSystemTable.class); 580 when(sysTable.readBackupInfo(backupId)).thenReturn(info); 581 when(sysTable.readBulkLoadedFiles(backupId)).thenReturn(bulkFiles); 582 583 FileSystem fs = mock(FileSystem.class); 584 585 try (MockedStatic<FileSystem> fsStatic = mockStatic(FileSystem.class)) { 586 fsStatic.when(() -> FileSystem.get(conf)).thenReturn(fs); 587 when(fs.delete(dummyPath)).thenReturn(true); // Simulate successful delete 588 589 // Create spy on BackupAdminImpl 590 BackupAdminImpl backupAdmin = spy(new BackupAdminImpl(conn)); 591 when(backupAdmin.isLastBackupSession(sysTable, table, 1500L)).thenReturn(true); 592 593 // No-ops for cleanup 594 doNothing().when(sysTable).deleteBackupInfo(backupId); 595 doNothing().when(sysTable).deleteBulkLoadedRows(any()); 596 597 try (MockedStatic<BackupUtils> staticMock = mockStatic(BackupUtils.class)) { 598 staticMock.when(() -> BackupUtils.cleanupBackupData(info, conf)) 599 .thenAnswer(invocation -> null); 600 601 // Execute method 602 int result = backupAdmin.deleteBackup(backupId, sysTable); 603 604 // Assertions 605 assertEquals(1, result); 606 verify(fs).delete(dummyPath); 607 verify(sysTable).deleteBulkLoadedRows(any()); 608 verify(sysTable).deleteBackupInfo(backupId); 609 staticMock.verify(() -> BackupUtils.cleanupBackupData(info, conf), times(1)); 610 } 611 } 612 } 613 614 /** 615 * Verifies that checkIfValidForMerge succeeds with valid INCREMENTAL, COMPLETE images from the 616 * same destination and no holes in the backup sequence. 617 */ 618 @Test 619 public void testCheckIfValidForMerge_validCase() throws IOException { 620 String[] ids = { "b1", "b2" }; 621 TableName t1 = TableName.valueOf("ns", "t1"); 622 String dest = "/backup/root"; 623 624 BackupInfo b1 = 625 createBackupInfo("b1", BackupType.INCREMENTAL, BackupState.COMPLETE, 1000L, dest, t1); 626 BackupInfo b2 = 627 createBackupInfo("b2", BackupType.INCREMENTAL, BackupState.COMPLETE, 2000L, dest, t1); 628 629 BackupSystemTable table = mock(BackupSystemTable.class); 630 when(table.readBackupInfo("b1")).thenReturn(b1); 631 when(table.readBackupInfo("b2")).thenReturn(b2); 632 when(table.getBackupHistory(any())).thenReturn(List.of(b1, b2)); 633 634 new BackupAdminImpl(mock(Connection.class)).checkIfValidForMerge(ids, table); 635 } 636 637 /** 638 * Verifies that checkIfValidForMerge fails if a FULL backup is included. 639 */ 640 @Test 641 public void testCheckIfValidForMerge_failsWithFullBackup() throws IOException { 642 String[] ids = { "b1" }; 643 TableName t1 = TableName.valueOf("ns", "t1"); 644 645 BackupInfo b1 = 646 createBackupInfo("b1", BackupType.FULL, BackupState.COMPLETE, 1000L, "/dest", t1); 647 648 BackupSystemTable table = mock(BackupSystemTable.class); 649 when(table.readBackupInfo("b1")).thenReturn(b1); 650 651 assertThrows(IOException.class, 652 () -> new BackupAdminImpl(mock(Connection.class)).checkIfValidForMerge(ids, table)); 653 } 654 655 /** 656 * Verifies that checkIfValidForMerge fails if one of the provided backup IDs is not found in the 657 * system table (i.e., null is returned). 658 */ 659 @Test 660 public void testCheckIfValidForMerge_failsWhenBackupInfoNotFound() throws IOException { 661 String[] ids = { "b_missing" }; 662 663 BackupSystemTable table = mock(BackupSystemTable.class); 664 when(table.readBackupInfo("b_missing")).thenReturn(null); 665 666 assertThrows(IOException.class, 667 () -> new BackupAdminImpl(mock(Connection.class)).checkIfValidForMerge(ids, table)); 668 } 669 670 /** 671 * Verifies that checkIfValidForMerge fails when backups come from different destinations. 672 */ 673 @Test 674 public void testCheckIfValidForMerge_failsWithDifferentDestinations() throws IOException { 675 String[] ids = { "b1", "b2" }; 676 TableName t1 = TableName.valueOf("ns", "t1"); 677 678 BackupInfo b1 = 679 createBackupInfo("b1", BackupType.INCREMENTAL, BackupState.COMPLETE, 1000L, "/dest1", t1); 680 BackupInfo b2 = 681 createBackupInfo("b2", BackupType.INCREMENTAL, BackupState.COMPLETE, 2000L, "/dest2", t1); 682 683 BackupSystemTable table = mock(BackupSystemTable.class); 684 when(table.readBackupInfo("b1")).thenReturn(b1); 685 when(table.readBackupInfo("b2")).thenReturn(b2); 686 687 assertThrows(IOException.class, 688 () -> new BackupAdminImpl(mock(Connection.class)).checkIfValidForMerge(ids, table)); 689 } 690 691 /** 692 * Verifies that checkIfValidForMerge fails if any backup is not in COMPLETE state. 693 */ 694 @Test 695 public void testCheckIfValidForMerge_failsWithNonCompleteState() throws IOException { 696 String[] ids = { "b1" }; 697 TableName t1 = TableName.valueOf("ns", "t1"); 698 699 BackupInfo b1 = 700 createBackupInfo("b1", BackupType.INCREMENTAL, BackupState.RUNNING, 1000L, "/dest", t1); 701 702 BackupSystemTable table = mock(BackupSystemTable.class); 703 when(table.readBackupInfo("b1")).thenReturn(b1); 704 705 assertThrows(IOException.class, 706 () -> new BackupAdminImpl(mock(Connection.class)).checkIfValidForMerge(ids, table)); 707 } 708 709 /** 710 * Verifies that checkIfValidForMerge fails when there is a "hole" in the backup sequence — i.e., 711 * a required image from the full backup history is missing in the input list. 712 */ 713 @Test 714 public void testCheckIfValidForMerge_failsWhenHoleInImages() throws IOException { 715 TableName t1 = TableName.valueOf("ns", "t1"); 716 String dest = "/backup/root"; 717 718 BackupInfo b1 = 719 createBackupInfo("b1", BackupType.INCREMENTAL, BackupState.COMPLETE, 1000L, dest, t1); 720 BackupInfo b2 = 721 createBackupInfo("b2", BackupType.INCREMENTAL, BackupState.COMPLETE, 2000L, dest, t1); 722 BackupInfo b3 = 723 createBackupInfo("b3", BackupType.INCREMENTAL, BackupState.COMPLETE, 3000L, dest, t1); 724 725 BackupSystemTable table = mock(BackupSystemTable.class); 726 when(table.readBackupInfo("b1")).thenReturn(b1); 727 when(table.readBackupInfo("b2")).thenReturn(b2); 728 when(table.readBackupInfo("b3")).thenReturn(b3); 729 730 when(table.getBackupHistory(any())).thenReturn(List.of(b1, b2, b3)); 731 732 // Simulate a "hole" by omitting b2 from images 733 String[] idsWithHole = { "b1", "b3" }; 734 assertThrows(IOException.class, 735 () -> new BackupAdminImpl(mock(Connection.class)).checkIfValidForMerge(idsWithHole, table)); 736 } 737 738 private BackupInfo createBackupInfo(String id, BackupType type, BackupInfo.BackupState state, 739 long ts, String dest, TableName... tables) { 740 BackupInfo info = new BackupInfo(); 741 info.setBackupId(id); 742 info.setType(type); 743 info.setState(state); 744 info.setStartTs(ts); 745 info.setBackupRootDir(dest); 746 info.setTables(List.of(tables)); 747 return info; 748 } 749}