This is an automated email from the ASF dual-hosted git repository. anmolnar pushed a commit to branch HBASE-29164 in repository https://gitbox.apache.org/repos/asf/hbase.git
commit 352aa6f5142af3b38f0861a6f31be5e86193b95c Author: Kevin Geiszler <[email protected]> AuthorDate: Wed Nov 5 10:43:49 2025 -0800 HBASE-29687: Extend IntegrationTestBackupRestore to handle continuous backups (#7417) Change-Id: I0c70c417b86c7732b58642a51c75897c35b16cb6 --- .../apache/hadoop/hbase/backup/BackupTestUtil.java | 21 +- .../hadoop/hbase/backup/TestContinuousBackup.java | 20 +- hbase-it/pom.xml | 7 + .../hbase/backup/IntegrationTestBackupRestore.java | 86 +++ .../backup/IntegrationTestBackupRestoreBase.java | 756 +++++++++++++++++++++ .../IntegrationTestContinuousBackupRestore.java | 103 +++ 6 files changed, 974 insertions(+), 19 deletions(-) diff --git a/hbase-backup/src/test/java/org/apache/hadoop/hbase/backup/BackupTestUtil.java b/hbase-backup/src/test/java/org/apache/hadoop/hbase/backup/BackupTestUtil.java index 5d49ed574d5..ba47fd2b442 100644 --- a/hbase-backup/src/test/java/org/apache/hadoop/hbase/backup/BackupTestUtil.java +++ b/hbase-backup/src/test/java/org/apache/hadoop/hbase/backup/BackupTestUtil.java @@ -18,13 +18,19 @@ package org.apache.hadoop.hbase.backup; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.apache.hadoop.hbase.backup.BackupRestoreConstants.CONTINUOUS_BACKUP_REPLICATION_PEER; import java.io.IOException; import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.hbase.HBaseTestingUtil; +import org.apache.hadoop.hbase.TableName; import org.apache.hadoop.hbase.backup.impl.BackupAdminImpl; import org.apache.hadoop.hbase.backup.impl.BackupManager; +import org.apache.hadoop.hbase.client.Admin; import org.apache.hadoop.hbase.client.Connection; import org.apache.hadoop.hbase.client.ConnectionFactory; +import org.apache.hadoop.hbase.replication.ReplicationPeerDescription; import org.apache.yetus.audience.InterfaceAudience; @InterfaceAudience.Private @@ -46,10 +52,21 @@ public class BackupTestUtil { } } - static void enableBackup(Configuration conf) { - // Enable backup + public static void enableBackup(Configuration conf) { conf.setBoolean(BackupRestoreConstants.BACKUP_ENABLE_KEY, true); BackupManager.decorateMasterConfiguration(conf); BackupManager.decorateRegionServerConfiguration(conf); } + + public static void verifyReplicationPeerSubscription(HBaseTestingUtil util, TableName tableName) + throws IOException { + try (Admin admin = util.getAdmin()) { + ReplicationPeerDescription peerDesc = admin.listReplicationPeers().stream() + .filter(peer -> peer.getPeerId().equals(CONTINUOUS_BACKUP_REPLICATION_PEER)).findFirst() + .orElseThrow(() -> new AssertionError("Replication peer not found")); + + assertTrue(peerDesc.getPeerConfig().getTableCFsMap().containsKey(tableName), + "Table should be subscribed to the replication peer"); + } + } } diff --git a/hbase-backup/src/test/java/org/apache/hadoop/hbase/backup/TestContinuousBackup.java b/hbase-backup/src/test/java/org/apache/hadoop/hbase/backup/TestContinuousBackup.java index 0e4f4a76a17..44eb8f44b40 100644 --- a/hbase-backup/src/test/java/org/apache/hadoop/hbase/backup/TestContinuousBackup.java +++ b/hbase-backup/src/test/java/org/apache/hadoop/hbase/backup/TestContinuousBackup.java @@ -18,7 +18,6 @@ package org.apache.hadoop.hbase.backup; import static org.apache.hadoop.hbase.backup.BackupRestoreConstants.CONF_CONTINUOUS_BACKUP_WAL_DIR; -import static org.apache.hadoop.hbase.backup.BackupRestoreConstants.CONTINUOUS_BACKUP_REPLICATION_PEER; import static org.apache.hadoop.hbase.backup.BackupRestoreConstants.OPTION_ENABLE_CONTINUOUS_BACKUP; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -35,8 +34,6 @@ import org.apache.hadoop.fs.Path; import org.apache.hadoop.hbase.TableName; import org.apache.hadoop.hbase.backup.impl.BackupManifest; import org.apache.hadoop.hbase.backup.impl.BackupSystemTable; -import org.apache.hadoop.hbase.client.Admin; -import org.apache.hadoop.hbase.replication.ReplicationPeerDescription; import org.apache.hadoop.hbase.testclassification.LargeTests; import org.apache.hadoop.util.ToolRunner; import org.junit.jupiter.api.AfterEach; @@ -108,7 +105,7 @@ public class TestContinuousBackup extends TestBackupBase { } // Verify replication peer subscription - verifyReplicationPeerSubscription(tableName); + BackupTestUtil.verifyReplicationPeerSubscription(TEST_UTIL, tableName); // Verify table is registered in Backup System Table verifyTableInBackupSystemTable(tableName); @@ -151,8 +148,8 @@ public class TestContinuousBackup extends TestBackupBase { } // Verify replication peer subscription for each table - verifyReplicationPeerSubscription(tableName1); - verifyReplicationPeerSubscription(tableName2); + BackupTestUtil.verifyReplicationPeerSubscription(TEST_UTIL, tableName1); + BackupTestUtil.verifyReplicationPeerSubscription(TEST_UTIL, tableName2); // Verify tables are registered in Backup System Table verifyTableInBackupSystemTable(tableName1); @@ -242,17 +239,6 @@ public class TestContinuousBackup extends TestBackupBase { } } - private void verifyReplicationPeerSubscription(TableName table) throws IOException { - try (Admin admin = TEST_UTIL.getAdmin()) { - ReplicationPeerDescription peerDesc = admin.listReplicationPeers().stream() - .filter(peer -> peer.getPeerId().equals(CONTINUOUS_BACKUP_REPLICATION_PEER)).findFirst() - .orElseThrow(() -> new AssertionError("Replication peer not found")); - - assertTrue(peerDesc.getPeerConfig().getTableCFsMap().containsKey(table), - "Table should be subscribed to the replication peer"); - } - } - String[] buildBackupArgs(String backupType, TableName[] tables, boolean continuousEnabled) { String tableNames = Arrays.stream(tables).map(TableName::getNameAsString).collect(Collectors.joining(",")); diff --git a/hbase-it/pom.xml b/hbase-it/pom.xml index 137987ce6c9..e45c37f2199 100644 --- a/hbase-it/pom.xml +++ b/hbase-it/pom.xml @@ -60,6 +60,13 @@ <type>test-jar</type> <scope>test</scope> </dependency> + <dependency> + <groupId>org.apache.hbase</groupId> + <artifactId>hbase-backup</artifactId> + <version>${project.version}</version> + <type>test-jar</type> + <scope>test</scope> + </dependency> <dependency> <groupId>org.apache.hbase</groupId> <artifactId>hbase-logging</artifactId> diff --git a/hbase-it/src/test/java/org/apache/hadoop/hbase/backup/IntegrationTestBackupRestore.java b/hbase-it/src/test/java/org/apache/hadoop/hbase/backup/IntegrationTestBackupRestore.java new file mode 100644 index 00000000000..99fbcb21a2b --- /dev/null +++ b/hbase-it/src/test/java/org/apache/hadoop/hbase/backup/IntegrationTestBackupRestore.java @@ -0,0 +1,86 @@ +/* + * 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; + +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; +import org.apache.hadoop.hbase.HBaseConfiguration; +import org.apache.hadoop.hbase.IntegrationTestingUtility; +import org.apache.hadoop.hbase.backup.impl.BackupManager; +import org.apache.hadoop.hbase.testclassification.IntegrationTests; +import org.apache.hadoop.util.ToolRunner; +import org.junit.Before; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * An integration test to detect regressions in HBASE-7912. Create a table with many regions, load + * data, perform series backup/load operations, then restore and verify data + * @see <a href="https://issues.apache.org/jira/browse/HBASE-7912">HBASE-7912</a> + * @see <a href="https://issues.apache.org/jira/browse/HBASE-14123">HBASE-14123</a> + */ +@Category(IntegrationTests.class) +public class IntegrationTestBackupRestore extends IntegrationTestBackupRestoreBase { + private static final String CLASS_NAME = IntegrationTestBackupRestore.class.getSimpleName(); + protected static final Logger LOG = LoggerFactory.getLogger(IntegrationTestBackupRestore.class); + + @Override + @Before + public void setUp() throws Exception { + util = new IntegrationTestingUtility(); + conf = util.getConfiguration(); + BackupTestUtil.enableBackup(conf); + LOG.info("Initializing cluster with {} region servers.", regionServerCount); + util.initializeCluster(regionServerCount); + LOG.info("Cluster initialized and ready"); + + backupRootDir = util.getDataTestDirOnTestFS() + Path.SEPARATOR + DEFAULT_BACKUP_ROOT_DIR; + LOG.info("The backup root directory is: {}", backupRootDir); + fs = FileSystem.get(conf); + } + + @Test + public void testBackupRestore() throws Exception { + LOG.info("Running backup and restore integration test with continuous backup disabled"); + createTables(CLASS_NAME); + runTestMulti(false); + } + + /** Returns status of CLI execution */ + @Override + public int runTestFromCommandLine() throws Exception { + // Check if backup is enabled + if (!BackupManager.isBackupEnabled(getConf())) { + System.err.println(BackupRestoreConstants.ENABLE_BACKUP); + return -1; + } + System.out.println(BackupRestoreConstants.VERIFY_BACKUP); + testBackupRestore(); + return 0; + } + + public static void main(String[] args) throws Exception { + Configuration conf = HBaseConfiguration.create(); + IntegrationTestingUtility.setUseDistributedCluster(conf); + int status = ToolRunner.run(conf, new IntegrationTestBackupRestore(), args); + System.exit(status); + } +} diff --git a/hbase-it/src/test/java/org/apache/hadoop/hbase/backup/IntegrationTestBackupRestoreBase.java b/hbase-it/src/test/java/org/apache/hadoop/hbase/backup/IntegrationTestBackupRestoreBase.java new file mode 100644 index 00000000000..56349fe055a --- /dev/null +++ b/hbase-it/src/test/java/org/apache/hadoop/hbase/backup/IntegrationTestBackupRestoreBase.java @@ -0,0 +1,756 @@ +/* + * 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; + +import static org.apache.hadoop.hbase.IntegrationTestingUtility.createPreSplitLoadTestTable; +import static org.apache.hadoop.hbase.backup.BackupRestoreConstants.CONF_CONTINUOUS_BACKUP_WAL_DIR; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.apache.hadoop.fs.FileStatus; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.LocatedFileStatus; +import org.apache.hadoop.fs.Path; +import org.apache.hadoop.fs.RemoteIterator; +import org.apache.hadoop.hbase.Cell; +import org.apache.hadoop.hbase.HBaseTestingUtil; +import org.apache.hadoop.hbase.IntegrationTestBase; +import org.apache.hadoop.hbase.ServerName; +import org.apache.hadoop.hbase.TableName; +import org.apache.hadoop.hbase.backup.impl.BackupAdminImpl; +import org.apache.hadoop.hbase.backup.impl.BackupSystemTable; +import org.apache.hadoop.hbase.backup.util.BackupUtils; +import org.apache.hadoop.hbase.chaos.actions.RestartRandomRsExceptMetaAction; +import org.apache.hadoop.hbase.chaos.monkies.PolicyBasedChaosMonkey; +import org.apache.hadoop.hbase.chaos.policies.PeriodicRandomActionPolicy; +import org.apache.hadoop.hbase.chaos.policies.Policy; +import org.apache.hadoop.hbase.client.ColumnFamilyDescriptor; +import org.apache.hadoop.hbase.client.ColumnFamilyDescriptorBuilder; +import org.apache.hadoop.hbase.client.Connection; +import org.apache.hadoop.hbase.client.Result; +import org.apache.hadoop.hbase.client.ResultScanner; +import org.apache.hadoop.hbase.client.Scan; +import org.apache.hadoop.hbase.client.Table; +import org.apache.hadoop.hbase.client.TableDescriptor; +import org.apache.hadoop.hbase.client.TableDescriptorBuilder; +import org.apache.hadoop.hbase.util.EnvironmentEdgeManager; +import org.junit.After; +import org.junit.Assert; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.hbase.thirdparty.com.google.common.base.MoreObjects; +import org.apache.hbase.thirdparty.com.google.common.collect.Lists; +import org.apache.hbase.thirdparty.com.google.common.util.concurrent.Uninterruptibles; +import org.apache.hbase.thirdparty.org.apache.commons.cli.CommandLine; + +/** + * An abstract base class that is used to run backup, restore, and delete integration tests. This + * class performs both full backups and incremental backups. Both continuous backup and + * non-continuous backup test cases are supported. The number of incremental backups performed + * depends on the number of iterations defined by the user. The class performs the backup/restore in + * a separate thread, where one thread is created per table. The number of tables is user-defined, + * along with other various configurations. + */ +public abstract class IntegrationTestBackupRestoreBase extends IntegrationTestBase { + protected static final Logger LOG = + LoggerFactory.getLogger(IntegrationTestBackupRestoreBase.class); + protected static final String NUMBER_OF_TABLES_KEY = "num_tables"; + protected static final String COLUMN_NAME = "f"; + protected static final String REGION_COUNT_KEY = "regions_per_rs"; + protected static final String REGIONSERVER_COUNT_KEY = "region_servers"; + protected static final String ROWS_PER_ITERATION_KEY = "rows_in_iteration"; + protected static final String NUM_ITERATIONS_KEY = "num_iterations"; + protected static final int DEFAULT_REGION_COUNT = 10; + protected static final int DEFAULT_REGIONSERVER_COUNT = 5; + protected static final int DEFAULT_NUMBER_OF_TABLES = 1; + protected static final int DEFAULT_NUM_ITERATIONS = 10; + protected static final int DEFAULT_ROWS_IN_ITERATION = 10000; + protected static final String SLEEP_TIME_KEY = "sleeptime"; + // short default interval because tests don't run very long. + protected static final long SLEEP_TIME_DEFAULT = 50000L; + protected static String DEFAULT_BACKUP_ROOT_DIR = "backupIT"; + + protected static int rowsInIteration; + protected static int regionsCountPerServer; + protected static int regionServerCount; + + protected static int numIterations; + protected static int numTables; + protected static TableName[] tableNames; + protected long sleepTime; + protected static Object lock = new Object(); + + protected FileSystem fs; + protected String backupRootDir; + + /* + * This class is used to run the backup and restore thread(s). Throwing an exception in this + * thread will not cause the test to fail, so the purpose of this class is to both kick off the + * backup and restore, as well as record any exceptions that occur so they can be thrown in the + * main thread. + */ + protected class BackupAndRestoreThread implements Runnable { + private final TableName table; + private final boolean isContinuousBackupEnabled; + private Throwable throwable; + + public BackupAndRestoreThread(TableName table, boolean isContinuousBackupEnabled) { + this.table = table; + this.isContinuousBackupEnabled = isContinuousBackupEnabled; + this.throwable = null; + } + + public Throwable getThrowable() { + return this.throwable; + } + + @Override + public void run() { + try { + LOG.info("Running backup and restore test for {} in thread {}", this.table, + Thread.currentThread()); + runTestSingle(this.table, isContinuousBackupEnabled); + } catch (Throwable t) { + LOG.error( + "An error occurred in thread {} when performing a backup and restore with table {}: ", + Thread.currentThread().getName(), this.table.getNameAsString(), t); + this.throwable = t; + } + } + } + + @After + public void tearDown() throws IOException { + LOG.info("Cleaning up after test."); + if (util.isDistributedCluster()) { + deleteTablesIfAny(); + LOG.info("Cleaning up after test. Deleted tables"); + cleanUpBackupDir(); + } + LOG.info("Restoring cluster."); + util.restoreCluster(); + LOG.info("Cluster restored."); + } + + @Override + public void setUpMonkey() throws Exception { + Policy p = + new PeriodicRandomActionPolicy(sleepTime, new RestartRandomRsExceptMetaAction(sleepTime)); + this.monkey = new PolicyBasedChaosMonkey(util, p); + startMonkey(); + } + + private void deleteTablesIfAny() throws IOException { + for (TableName table : tableNames) { + util.deleteTableIfAny(table); + } + } + + protected void createTables(String tableBaseName) throws Exception { + tableNames = new TableName[numTables]; + for (int i = 0; i < numTables; i++) { + tableNames[i] = TableName.valueOf(tableBaseName + ".table." + i); + } + for (TableName table : tableNames) { + createTable(table); + } + } + + /** + * Creates a directory specified by backupWALDir and sets this directory to + * CONF_CONTINUOUS_BACKUP_WAL_DIR in the configuration. + */ + protected void createAndSetBackupWalDir() throws IOException { + Path root = util.getDataTestDirOnTestFS(); + Path backupWalDir = new Path(root, "backupWALDir"); + FileSystem fs = FileSystem.get(conf); + fs.mkdirs(backupWalDir); + conf.set(CONF_CONTINUOUS_BACKUP_WAL_DIR, backupWalDir.toString()); + LOG.info( + "The continuous backup WAL directory has been created and set in the configuration to: {}", + backupWalDir); + } + + private void cleanUpBackupDir() throws IOException { + FileSystem fs = FileSystem.get(util.getConfiguration()); + fs.delete(new Path(backupRootDir), true); + } + + /** + * This is the main driver method used by tests that extend this abstract base class. This method + * starts one backup and restore thread per table. + * @param isContinuousBackupEnabled Boolean flag used to specify if the backups should have + * continuous backup enabled. + */ + protected void runTestMulti(boolean isContinuousBackupEnabled) { + Thread[] workers = new Thread[numTables]; + BackupAndRestoreThread[] backupAndRestoreThreads = new BackupAndRestoreThread[numTables]; + for (int i = 0; i < numTables; i++) { + final TableName table = tableNames[i]; + BackupAndRestoreThread backupAndRestoreThread = + new BackupAndRestoreThread(table, isContinuousBackupEnabled); + backupAndRestoreThreads[i] = backupAndRestoreThread; + workers[i] = new Thread(backupAndRestoreThread); + workers[i].start(); + } + // Wait for all workers to finish and check for errors + Throwable error = null; + Throwable threadThrowable; + for (int i = 0; i < numTables; i++) { + Uninterruptibles.joinUninterruptibly(workers[i]); + threadThrowable = backupAndRestoreThreads[i].getThrowable(); + if (threadThrowable == null) { + continue; + } + if (error == null) { + error = threadThrowable; + } else { + error.addSuppressed(threadThrowable); + } + } + // Throw any found errors after all threads have completed + if (error != null) { + throw new AssertionError("An error occurred in a backup and restore thread", error); + } + LOG.info("IT backup & restore finished"); + } + + /** + * This method is what performs the actual backup, restore, merge, and delete operations. This + * method is run in a separate thread. It first performs a full backup. After, it iteratively + * performs a series of incremental backups and restores. Later, it deletes the backups. + * @param tableName The table the backups are performed on + * @param isContinuousBackupEnabled Boolean flag used to indicate if the backups should have + * continuous backup enabled. + */ + private void runTestSingle(TableName tableName, boolean isContinuousBackupEnabled) + throws IOException, InterruptedException { + String enabledOrDisabled = isContinuousBackupEnabled ? "enabled" : "disabled"; + List<String> backupIds = new ArrayList<>(); + + try (Connection conn = util.getConnection(); BackupAdmin client = new BackupAdminImpl(conn); + Table tableConn = conn.getTable(tableName)) { + loadData(tableName, rowsInIteration); + + // First create a full backup for the table + LOG.info("Creating full backup image for {} with continuous backup {}", tableName, + enabledOrDisabled); + List<TableName> tables = Lists.newArrayList(tableName); + BackupRequest.Builder builder = new BackupRequest.Builder(); + BackupRequest request = builder.withBackupType(BackupType.FULL).withTableList(tables) + .withTargetRootDir(backupRootDir).withContinuousBackupEnabled(isContinuousBackupEnabled) + .build(); + + String fullBackupId = backup(request, client, backupIds); + LOG.info("Created full backup with ID: {}", fullBackupId); + + verifySnapshotExists(tableName, fullBackupId); + + // Run full backup verifications specific to continuous backup + if (isContinuousBackupEnabled) { + BackupTestUtil.verifyReplicationPeerSubscription(util, tableName); + Path backupWALs = verifyWALsDirectoryExists(); + Path walPartitionDir = verifyWALPartitionDirExists(backupWALs); + verifyBackupWALFiles(walPartitionDir); + } + + // Now continue with incremental backups + String incrementalBackupId; + for (int count = 1; count <= numIterations; count++) { + LOG.info("{} - Starting incremental backup iteration {} of {} for {}", + Thread.currentThread().getName(), count, numIterations, tableName); + loadData(tableName, rowsInIteration); + if (isContinuousBackupEnabled) { + long latestPutTimestamp = getLatestPutTimestamp(tableConn); + waitForCheckpointTimestampsToUpdate(conn, latestPutTimestamp, tableName); + } + + // Do incremental backup + LOG.info("Creating incremental backup number {} with continuous backup {} for {}", count, + enabledOrDisabled, tableName); + builder = new BackupRequest.Builder(); + request = builder.withBackupType(BackupType.INCREMENTAL).withTableList(tables) + .withTargetRootDir(backupRootDir).withContinuousBackupEnabled(isContinuousBackupEnabled) + .build(); + incrementalBackupId = backup(request, client, backupIds); + LOG.info("Created incremental backup with ID: {}", incrementalBackupId); + + // Restore table using backup taken "two backups ago" + // On the first iteration, this backup will be the full backup + String previousBackupId = backupIds.get(backupIds.size() - 2); + if (previousBackupId.equals(fullBackupId)) { + LOG.info("Restoring {} using original full backup with ID: {}", tableName, + previousBackupId); + } else { + LOG.info("Restoring {} using second most recent incremental backup with ID: {}", + tableName, previousBackupId); + } + restoreTableAndVerifyRowCount(conn, client, tableName, previousBackupId, + (long) rowsInIteration * count); + + // Restore table using the most recently created incremental backup + LOG.info("Restoring {} using most recent incremental backup with ID: {}", tableName, + incrementalBackupId); + restoreTableAndVerifyRowCount(conn, client, tableName, incrementalBackupId, + (long) rowsInIteration * (count + 1)); + LOG.info("{} - Finished incremental backup iteration {} of {} for {}", + Thread.currentThread().getName(), count, numIterations, tableName); + } + + // Now merge all incremental and restore + String[] incrementalBackupIds = getAllIncrementalBackupIds(backupIds); + merge(incrementalBackupIds, client); + verifyBackupExistenceAfterMerge(backupIds); + removeNonexistentBackups(backupIds); + // Restore the last incremental backup + incrementalBackupId = incrementalBackupIds[incrementalBackupIds.length - 1]; + // restore incremental backup for table, with overwrite + TableName[] tablesToRestoreFrom = new TableName[] { tableName }; + restore(createRestoreRequest(backupRootDir, incrementalBackupId, false, tablesToRestoreFrom, + null, true), client); + Table hTable = conn.getTable(tableName); + Assert.assertEquals(rowsInIteration * (numIterations + 1), + HBaseTestingUtil.countRows(hTable)); + hTable.close(); + + // Create another incremental backup to show it can be deleted on its own + backup(request, client, backupIds); + deleteMostRecentIncrementalBackup(backupIds, client); + // The full backup and the second most recent incremental backup should still exist + assertEquals(2, backupIds.size()); + verifyAllBackupsExist(backupIds); + // Delete the full backup, which should also automatically delete any incremental backups that + // depend on it + LOG.info("Deleting full backup: {}. This will also delete any remaining incremental backups", + fullBackupId); + delete(new String[] { fullBackupId }, client); + verifyNoBackupsExist(backupIds); + } + } + + private void createTable(TableName tableName) throws Exception { + long startTime, endTime; + + TableDescriptorBuilder builder = TableDescriptorBuilder.newBuilder(tableName); + + TableDescriptor desc = builder.build(); + ColumnFamilyDescriptorBuilder cbuilder = + ColumnFamilyDescriptorBuilder.newBuilder(COLUMN_NAME.getBytes(Charset.defaultCharset())); + ColumnFamilyDescriptor[] columns = new ColumnFamilyDescriptor[] { cbuilder.build() }; + LOG.info("Creating table {} with {} splits.", tableName, + regionsCountPerServer * regionServerCount); + startTime = EnvironmentEdgeManager.currentTime(); + createPreSplitLoadTestTable(util.getConfiguration(), desc, columns, regionsCountPerServer); + util.waitTableAvailable(tableName); + endTime = EnvironmentEdgeManager.currentTime(); + LOG.info("Pre-split table created successfully in {}ms.", (endTime - startTime)); + } + + private void loadData(TableName table, int numRows) throws IOException { + Connection conn = util.getConnection(); + // #0- insert some data to a table + Table t1 = conn.getTable(table); + util.loadRandomRows(t1, new byte[] { 'f' }, 100, numRows); + // flush table + conn.getAdmin().flush(TableName.valueOf(table.getName())); + } + + private String backup(BackupRequest request, BackupAdmin client, List<String> backupIds) + throws IOException { + String backupId = client.backupTables(request); + assertTrue(checkSucceeded(backupId)); + verifyBackupExists(backupId); + backupIds.add(backupId); + return backupId; + } + + private void restore(RestoreRequest request, BackupAdmin client) throws IOException { + client.restore(request); + } + + private void merge(String[] backupIds, BackupAdmin client) throws IOException { + client.mergeBackups(backupIds); + } + + private void delete(String[] backupIds, BackupAdmin client) throws IOException { + client.deleteBackups(backupIds); + } + + /** + * Verifies a snapshot's "data.manifest" file exists after a full backup has been performed for a + * table. The "data.manifest" file's path will look like the following: + * .../backupRootDir/backup_1760572298945/default/TABLE_NAME/.hbase-snapshot/ + * snapshot_1760572306407_default_TABLE_NAME/data.manifest + */ + private void verifySnapshotExists(TableName tableName, String backupId) throws IOException { + RemoteIterator<LocatedFileStatus> fileStatusIterator = + fs.listFiles(new Path(backupRootDir, backupId), true); + Path dataManifestPath = null; + while (fileStatusIterator.hasNext()) { + LocatedFileStatus fileStatus = fileStatusIterator.next(); + if (fileStatus.getPath().getName().endsWith("data.manifest")) { + dataManifestPath = fileStatus.getPath(); + LOG.info("Found snapshot manifest for table '{}' at: {}", tableName, dataManifestPath); + } + } + + if (dataManifestPath == null) { + fail("Could not find snapshot data manifest for table '" + tableName + "'"); + } + } + + /** Verifies the .../backupWALDir/WALs directory exists and returns its Path */ + private Path verifyWALsDirectoryExists() throws IOException { + String backupWALDir = conf.get(CONF_CONTINUOUS_BACKUP_WAL_DIR); + Path backupWALs = new Path(backupWALDir, "WALs"); + assertTrue( + "There should be a WALs directory inside of the backup WAL directory at: " + backupWALDir, + fs.exists(backupWALs)); + return backupWALs; + } + + /** + * Waits for a WAL partition directory to exist inside the backup WAL directory. The directory + * should be something like: .../backupWALDir/WALs/2025-10-17. The directory's existence is either + * eventually asserted, or an assertion error is thrown if it does not exist past the wait + * deadline. This verification is to be used for full backups with continuous backup enabled. + * @param backupWALs The directory that should contain the partition directory. i.e. + * .../backupWALDir/WALs + * @return The Path to the WAL partition directory + */ + private Path verifyWALPartitionDirExists(Path backupWALs) + throws IOException, InterruptedException { + long currentTimeMs = System.currentTimeMillis(); + String currentDateUTC = BackupUtils.formatToDateString(currentTimeMs); + Path walPartitionDir = new Path(backupWALs, currentDateUTC); + int waitTimeSec = 30; + while (true) { + try { + assertTrue("A backup WALs subdirectory with today's date should exist: " + walPartitionDir, + fs.exists(walPartitionDir)); + // The directory exists - stop waiting + break; + } catch (AssertionError e) { + // Reach here when the directory currently does not exist + if ((System.currentTimeMillis() - currentTimeMs) >= waitTimeSec * 1000) { + throw new AssertionError(e); + } + LOG.info("Waiting up to {} seconds for WAL partition directory to exist: {}", waitTimeSec, + walPartitionDir); + Thread.sleep(1000); + } + } + return walPartitionDir; + } + + /** + * Verifies the WAL partition directory contains a backup WAL file. The WAL file's path will look + * something like the following: + * .../backupWALDir/WALs/2025-10-17/wal_file.1760738249595.1880be89-0b69-4bad-8d0e-acbf25c63b7e + * @param walPartitionDir The date directory for a backup WAL i.e. + * .../backupWALDir/WALs/2025-10-17 + */ + private void verifyBackupWALFiles(Path walPartitionDir) throws IOException { + FileStatus[] fileStatuses = fs.listStatus(walPartitionDir); + for (FileStatus fileStatus : fileStatuses) { + String walFileName = fileStatus.getPath().getName(); + String[] splitName = walFileName.split("\\."); + assertEquals("The WAL partition directory should only have files that start with 'wal_file'", + "wal_file", splitName[0]); + assertEquals( + "The timestamp in the WAL file's name should match the " + + "date for the WAL partition directory", + walPartitionDir.getName(), BackupUtils.formatToDateString(Long.parseLong(splitName[1]))); + } + } + + /** + * Checks if all timestamps in a map with ServerName and timestamp key-values pairs are past the + * provided timestamp threshold. + * @param timestamps Map with ServerName and timestamp key-value pairs + * @param threshold Timestamp to check all timestamp values in the map against + * @return True if all timestamps values in the map have met or are path the timestamp threshold; + * False otherwise + */ + private boolean areAllTimestampsPastThreshold(Map<ServerName, Long> timestamps, long threshold, + TableName tableName) { + boolean haveAllTimestampsReachedThreshold = true; + LOG.info("Checking if each region server in the replication check point " + + "has caught up to the latest Put on {}", tableName.getNameAsString()); + for (Map.Entry<ServerName, Long> entry : timestamps.entrySet()) { + LOG.debug("host={}, checkpoint timestamp={}, latest put timestamp={}, caught up={}", + entry.getKey(), entry.getValue(), threshold, entry.getValue() >= threshold); + if (entry.getValue() < threshold) { + // Not returning right away so all hosts and timestamps are logged + haveAllTimestampsReachedThreshold = false; + } + } + return haveAllTimestampsReachedThreshold; + } + + /** + * Waits for the replication checkpoint timestamp of each region server to meet or pass the + * timestamp of the latest Put operation on the backed-up table. + * @param conn Minicluster connection + * @param latestPutTimestamp Timestamp of the latest Put operation on the backed-up table + */ + private void waitForCheckpointTimestampsToUpdate(Connection conn, long latestPutTimestamp, + TableName tableName) throws IOException, InterruptedException { + BackupSystemTable backupSystemTable = new BackupSystemTable(conn); + Map<ServerName, Long> checkpointTimestamps = backupSystemTable.getBackupCheckpointTimestamps(); + int i = 0; + int sleepTimeSec = 10; + int waitThresholdMs = 15 * 60 * 1000; + long waitStartTime = System.currentTimeMillis(); + while (!areAllTimestampsPastThreshold(checkpointTimestamps, latestPutTimestamp, tableName)) { + if ((System.currentTimeMillis() - waitStartTime) >= waitThresholdMs) { + throw new RuntimeException("Timed out waiting for the replication checkpoint timestamp of " + + "each region server to catch up with timestamp of the latest Put on " + + tableName.getNameAsString()); + } + LOG.info( + "Waiting {} seconds for the replication checkpoint timestamp for each region server " + + "to catch up with the timestamp of the latest Put on {}", + sleepTimeSec, tableName.getNameAsString()); + Thread.sleep(sleepTimeSec * 1000); + checkpointTimestamps = backupSystemTable.getBackupCheckpointTimestamps(); + i++; + } + LOG.info("Done waiting. Total wait time: {} seconds", i * sleepTimeSec); + } + + /** + * Scans the backed-up table and returns the timestamp (ms) of the latest Put operation on the + * table. + * @param table The backed-up table to scan + * @return Timestamp of the most recent Put on the backed-up table + */ + private long getLatestPutTimestamp(Table table) throws IOException { + Scan scan = new Scan(); + ResultScanner resultScanner = table.getScanner(scan); + long timestamp = 0; + for (Result result : resultScanner) { + for (Cell cell : result.rawCells()) { + if (cell.getTimestamp() > timestamp) { + timestamp = cell.getTimestamp(); + } + } + } + return timestamp; + } + + /** + * Restores a table using the provided backup ID and ensure the table has the correct row count + * after + */ + private void restoreTableAndVerifyRowCount(Connection conn, BackupAdmin client, TableName table, + String backupId, long expectedRows) throws IOException { + TableName[] tablesRestoreIncMultiple = new TableName[] { table }; + restore( + createRestoreRequest(backupRootDir, backupId, false, tablesRestoreIncMultiple, null, true), + client); + Table hTable = conn.getTable(table); + Assert.assertEquals(expectedRows, HBaseTestingUtil.countRows(hTable)); + hTable.close(); + } + + /** + * Uses the list of all backup IDs to return a sublist of incremental backup IDs. This method + * assumes the first backup in the list is a full backup, followed by incremental backups. + */ + private String[] getAllIncrementalBackupIds(List<String> backupIds) { + int size = backupIds.size(); + backupIds = backupIds.subList(1, size); + String[] arr = new String[size - 1]; + backupIds.toArray(arr); + return arr; + } + + /** Returns status of backup */ + protected boolean checkSucceeded(String backupId) throws IOException { + BackupInfo status = getBackupInfo(backupId); + if (status == null) { + return false; + } + return status.getState() == BackupInfo.BackupState.COMPLETE; + } + + private BackupInfo getBackupInfo(String backupId) throws IOException { + try (BackupSystemTable table = new BackupSystemTable(util.getConnection())) { + return table.readBackupInfo(backupId); + } + } + + /** + * Get restore request. + * @param backupId backup ID + * @param check check the backup + * @param fromTables table names to restore from + * @param toTables new table names to restore to + * @param isOverwrite overwrite the table(s) + * @return an instance of RestoreRequest + */ + public RestoreRequest createRestoreRequest(String backupRootDir, String backupId, boolean check, + TableName[] fromTables, TableName[] toTables, boolean isOverwrite) { + RestoreRequest.Builder builder = new RestoreRequest.Builder(); + return builder.withBackupRootDir(backupRootDir).withBackupId(backupId).withCheck(check) + .withFromTables(fromTables).withToTables(toTables).withOvewrite(isOverwrite).build(); + } + + /** + * Iterates through the list of backups and verifies the full backup and latest incremental backup + * still exist, while also verifying all other backups no longer exist. This method is meant to be + * run after all incremental backups have been merged. + */ + private void verifyBackupExistenceAfterMerge(List<String> backupIds) throws IOException { + String fullBackupId = backupIds.get(0); + String mostRecentIncrementalBackup = backupIds.get(backupIds.size() - 1); + for (String backupId : backupIds) { + if (backupId.equals(fullBackupId) || backupId.equals(mostRecentIncrementalBackup)) { + verifyBackupExists(backupId); + } else { + verifyBackupDoesNotExist(backupId); + } + } + } + + private void removeNonexistentBackups(List<String> backupIds) throws IOException { + List<String> backupsToRemove = new ArrayList<>(); + for (String backupId : backupIds) { + if (!fs.exists(new Path(backupRootDir, backupId))) { + backupsToRemove.add(backupId); + } + } + for (String backupId : backupsToRemove) { + LOG.info("Removing {} from list of backup IDs since it no longer exists", backupId); + backupIds.remove(backupId); + } + } + + /** + * Performs the delete command for the most recently taken incremental backup, and also removes + * this backup from the list of backup IDs. + */ + private void deleteMostRecentIncrementalBackup(List<String> backupIds, BackupAdmin client) + throws IOException { + String incrementalBackupId = backupIds.get(backupIds.size() - 1); + LOG.info("Deleting the most recently created incremental backup: {}", incrementalBackupId); + verifyBackupExists(incrementalBackupId); + delete(new String[] { incrementalBackupId }, client); + backupIds.remove(backupIds.size() - 1); + verifyBackupDoesNotExist(incrementalBackupId); + } + + /** + * Verifies all backups in the list of backup IDs actually exist on the filesystem. + */ + private void verifyAllBackupsExist(List<String> backupIds) throws IOException { + for (String backupId : backupIds) { + verifyBackupExists(backupId); + } + } + + /** + * Verifies zero backups in the list of backup IDs exist on the filesystem. + */ + private void verifyNoBackupsExist(List<String> backupIds) throws IOException { + for (String backupId : backupIds) { + verifyBackupDoesNotExist(backupId); + } + } + + private void verifyBackupExists(String backupId) throws IOException { + assertTrue("Backup " + backupId + " should exist inside of " + backupRootDir, + fs.exists(new Path(backupRootDir, backupId))); + } + + private void verifyBackupDoesNotExist(String backupId) throws IOException { + assertFalse("Backup " + backupId + " should not exist inside of " + backupRootDir, + fs.exists(new Path(backupRootDir, backupId))); + } + + @Override + public void setUpCluster() throws Exception { + util = getTestingUtil(getConf()); + conf = getConf(); + BackupTestUtil.enableBackup(conf); + LOG.debug("Initializing/checking cluster has {} servers", regionServerCount); + util.initializeCluster(regionServerCount); + LOG.debug("Done initializing/checking cluster"); + } + + @Override + public TableName getTablename() { + // That is only valid when Monkey is CALM (no monkey) + return null; + } + + @Override + protected Set<String> getColumnFamilies() { + // That is only valid when Monkey is CALM (no monkey) + return null; + } + + @Override + protected void addOptions() { + addOptWithArg(REGIONSERVER_COUNT_KEY, + "Total number of region servers. Default: '" + DEFAULT_REGIONSERVER_COUNT + "'"); + addOptWithArg(REGION_COUNT_KEY, "Total number of regions. Default: " + DEFAULT_REGION_COUNT); + addOptWithArg(ROWS_PER_ITERATION_KEY, + "Total number of data rows to be loaded during one iteration." + " Default: " + + DEFAULT_ROWS_IN_ITERATION); + addOptWithArg(NUM_ITERATIONS_KEY, + "Total number iterations." + " Default: " + DEFAULT_NUM_ITERATIONS); + addOptWithArg(NUMBER_OF_TABLES_KEY, + "Total number of tables in the test." + " Default: " + DEFAULT_NUMBER_OF_TABLES); + addOptWithArg(SLEEP_TIME_KEY, "Sleep time of chaos monkey in ms " + + "to restart random region server. Default: " + SLEEP_TIME_DEFAULT); + } + + @Override + protected void processOptions(CommandLine cmd) { + super.processOptions(cmd); + regionsCountPerServer = Integer + .parseInt(cmd.getOptionValue(REGION_COUNT_KEY, Integer.toString(DEFAULT_REGION_COUNT))); + regionServerCount = Integer.parseInt( + cmd.getOptionValue(REGIONSERVER_COUNT_KEY, Integer.toString(DEFAULT_REGIONSERVER_COUNT))); + rowsInIteration = Integer.parseInt( + cmd.getOptionValue(ROWS_PER_ITERATION_KEY, Integer.toString(DEFAULT_ROWS_IN_ITERATION))); + numIterations = Integer + .parseInt(cmd.getOptionValue(NUM_ITERATIONS_KEY, Integer.toString(DEFAULT_NUM_ITERATIONS))); + numTables = Integer.parseInt( + cmd.getOptionValue(NUMBER_OF_TABLES_KEY, Integer.toString(DEFAULT_NUMBER_OF_TABLES))); + sleepTime = + Long.parseLong(cmd.getOptionValue(SLEEP_TIME_KEY, Long.toString(SLEEP_TIME_DEFAULT))); + + LOG.info(MoreObjects.toStringHelper("Parsed Options") + .add(REGION_COUNT_KEY, regionsCountPerServer).add(REGIONSERVER_COUNT_KEY, regionServerCount) + .add(ROWS_PER_ITERATION_KEY, rowsInIteration).add(NUM_ITERATIONS_KEY, numIterations) + .add(NUMBER_OF_TABLES_KEY, numTables).add(SLEEP_TIME_KEY, sleepTime).toString()); + } + +} diff --git a/hbase-it/src/test/java/org/apache/hadoop/hbase/backup/IntegrationTestContinuousBackupRestore.java b/hbase-it/src/test/java/org/apache/hadoop/hbase/backup/IntegrationTestContinuousBackupRestore.java new file mode 100644 index 00000000000..61ce3eea2b1 --- /dev/null +++ b/hbase-it/src/test/java/org/apache/hadoop/hbase/backup/IntegrationTestContinuousBackupRestore.java @@ -0,0 +1,103 @@ +/* + * 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; + +import static org.apache.hadoop.hbase.backup.replication.ContinuousBackupReplicationEndpoint.CONF_BACKUP_MAX_WAL_SIZE; +import static org.apache.hadoop.hbase.backup.replication.ContinuousBackupReplicationEndpoint.CONF_STAGED_WAL_FLUSH_INITIAL_DELAY; +import static org.apache.hadoop.hbase.backup.replication.ContinuousBackupReplicationEndpoint.CONF_STAGED_WAL_FLUSH_INTERVAL; +import static org.apache.hadoop.hbase.mapreduce.WALPlayer.IGNORE_EMPTY_FILES; +import static org.apache.hadoop.hbase.mapreduce.WALPlayer.IGNORE_MISSING_FILES; +import static org.apache.hadoop.hbase.replication.regionserver.ReplicationMarkerChore.REPLICATION_MARKER_ENABLED_KEY; + +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; +import org.apache.hadoop.hbase.HBaseConfiguration; +import org.apache.hadoop.hbase.IntegrationTestingUtility; +import org.apache.hadoop.hbase.backup.impl.BackupManager; +import org.apache.hadoop.hbase.testclassification.IntegrationTests; +import org.apache.hadoop.util.ToolRunner; +import org.junit.Before; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * An integration test to detect regressions in HBASE-28957. Create a table with many regions, load + * data, perform series backup/load operations with continuous backup enabled, then restore and + * verify data. + * @see <a href="https://issues.apache.org/jira/browse/HBASE-28957">HBASE-28957</a> + */ +@Category(IntegrationTests.class) +public class IntegrationTestContinuousBackupRestore extends IntegrationTestBackupRestoreBase { + private static final String CLASS_NAME = + IntegrationTestContinuousBackupRestore.class.getSimpleName(); + protected static final Logger LOG = + LoggerFactory.getLogger(IntegrationTestContinuousBackupRestore.class); + + @Override + @Before + public void setUp() throws Exception { + util = new IntegrationTestingUtility(); + conf = util.getConfiguration(); + BackupTestUtil.enableBackup(conf); + conf.set(CONF_BACKUP_MAX_WAL_SIZE, "10240"); + conf.set(CONF_STAGED_WAL_FLUSH_INITIAL_DELAY, "10"); + conf.set(CONF_STAGED_WAL_FLUSH_INTERVAL, "10"); + conf.setBoolean(REPLICATION_MARKER_ENABLED_KEY, true); + conf.setBoolean(IGNORE_EMPTY_FILES, true); + conf.setBoolean(IGNORE_MISSING_FILES, true); + + LOG.info("Initializing cluster with {} region server(s)", regionServerCount); + util.initializeCluster(regionServerCount); + LOG.info("Cluster initialized and ready"); + + backupRootDir = util.getDataTestDirOnTestFS() + Path.SEPARATOR + DEFAULT_BACKUP_ROOT_DIR; + LOG.info("The backup root directory is: {}", backupRootDir); + createAndSetBackupWalDir(); + fs = FileSystem.get(conf); + } + + @Test + public void testContinuousBackupRestore() throws Exception { + LOG.info("Running backup and restore integration test with continuous backup enabled"); + createTables(CLASS_NAME); + runTestMulti(true); + } + + /** Returns status of CLI execution */ + @Override + public int runTestFromCommandLine() throws Exception { + // Check if backup is enabled + if (!BackupManager.isBackupEnabled(getConf())) { + System.err.println(BackupRestoreConstants.ENABLE_BACKUP); + return -1; + } + System.out.println(BackupRestoreConstants.VERIFY_BACKUP); + testContinuousBackupRestore(); + return 0; + } + + public static void main(String[] args) throws Exception { + Configuration conf = HBaseConfiguration.create(); + IntegrationTestingUtility.setUseDistributedCluster(conf); + int status = ToolRunner.run(conf, new IntegrationTestContinuousBackupRestore(), args); + System.exit(status); + } +}
