DieterDePaepe commented on code in PR #8694:
URL: https://github.com/apache/hbase/pull/8694#discussion_r4286920215


##########
hbase-backup/src/main/java/org/apache/hadoop/hbase/backup/impl/FullTableBackupClient.java:
##########
@@ -248,6 +263,75 @@ private void performSnapshots(Admin admin) throws 
IOException {
     }
   }
 
+  /**
+   * Computes the per-host log boundaries stored by this full backup, using

Review Comment:
   I had a hard time parsing this text when starting the review, though it's 
completely correct. Some thoughts - pick what you agree with:
   
   - Perhaps introduce a short starting paragraph that explains the purpose, 
list the details in later paragraphs.
   - `are included in the next incremental backup` -> `will be included`
   - The text `The live servers are read ... treating live servers as dead.` 
might be more suited for inline documentation (as its an implementation detail)?



##########
hbase-backup/src/main/java/org/apache/hadoop/hbase/backup/util/BackupUtils.java:
##########
@@ -206,6 +208,84 @@ public static String parseHostNameFromLogFile(Path p) {
     }
   }
 
+  /**
+   * Returns the log roll result of every region server that took part in the 
log roll that happened
+   * between reading {@code previousLogRolls} and {@code latestLogRolls}. A 
region server that did
+   * not take part (for example because it is offline) keeps its old roll 
result, which is never
+   * removed, so it is recognized by its roll result not changing.
+   * @param previousLogRolls roll results by host, read before the log roll
+   * @param latestLogRolls   roll results by host, read after the log roll
+   * @return roll results by host, for the hosts that took part in the log roll
+   */
+  public static Map<String, Long> getRolledHosts(Map<String, Long> 
previousLogRolls,
+    Map<String, Long> latestLogRolls) {
+    return latestLogRolls.entrySet().stream()
+      .filter(entry -> 
!entry.getValue().equals(previousLogRolls.get(entry.getKey())))
+      .collect(Collectors.toMap(Entry::getKey, Entry::getValue));
+  }
+
+  /**
+   * Computes the per-host log boundaries stored by a backup that has no 
previous boundaries to
+   * carry forward, such as a full backup. See
+   * {@link #computeLogBoundaries(Map, Map, Set, Collection, Collection)}.
+   */
+  public static Map<String, Long> computeLogBoundaries(Map<String, Long> 
rolledHosts,
+    Collection<String> coveredLogs, Collection<String> pendingLogs) throws 
IOException {
+    return computeLogBoundaries(rolledHosts, Collections.emptyMap(), 
Collections.emptySet(),
+      coveredLogs, pendingLogs);
+  }
+
+  /**
+   * Computes the per-host log boundaries stored by a backup. Everything up to 
a host's boundary is
+   * covered by backups, so later incremental backups only include that host's 
logs that are newer.
+   * <ul>
+   * <li>A host that took part in the backup's log roll gets its roll 
result.</li>
+   * <li>Any other host gets the creation time of its newest covered log, 
capped to just below its
+   * oldest pending log, so that pending logs are included in a later 
backup.</li>
+   * <li>A host without covered or pending logs keeps its previous boundary 
while it still has logs,
+   * and otherwise gets no boundary.</li>
+   * </ul>
+   * Capping is needed because a host's logs can still be pending while some 
of its newer logs are
+   * already covered, for example when a dead server's logs are split and 
archived out of order. A

Review Comment:
   Suggestion: 
   ```
      * already covered, for example when a dead server's logs are split and 
archived out of order. As
      * a result, the next backup includes the covered logs that are newer than 
the cap a second time:
      * losing the pending logs is worse than repeating edits that are already 
backed up. A host is also...
   ```



##########
hbase-backup/src/test/java/org/apache/hadoop/hbase/backup/TestBackupOfflineRS.java:
##########
@@ -0,0 +1,439 @@
+/*
+ * 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.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import org.apache.hadoop.hbase.HBaseTestingUtil;
+import org.apache.hadoop.hbase.ServerName;
+import org.apache.hadoop.hbase.SingleProcessHBaseCluster;
+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.impl.FullTableBackupClient;
+import org.apache.hadoop.hbase.backup.impl.TableBackupClient;
+import org.apache.hadoop.hbase.backup.util.BackupUtils;
+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.client.Delete;
+import org.apache.hadoop.hbase.client.Get;
+import org.apache.hadoop.hbase.client.Put;
+import org.apache.hadoop.hbase.client.RegionInfo;
+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.master.HMaster;
+import org.apache.hadoop.hbase.master.procedure.ServerCrashProcedure;
+import org.apache.hadoop.hbase.regionserver.HRegion;
+import org.apache.hadoop.hbase.regionserver.HRegionServer;
+import org.apache.hadoop.hbase.testclassification.LargeTests;
+import org.apache.hadoop.hbase.util.Bytes;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.hbase.thirdparty.com.google.common.collect.Lists;
+
+/**
+ * Tests that WAL files from offline/inactive RegionServers are handled 
correctly during backup.
+ * Specifically verifies that WALs from an offline RS are:
+ * <ol>
+ * <li>Backed up once in the first backup after the RS goes offline</li>
+ * <li>NOT re-backed up in subsequent backups</li>
+ * </ol>
+ */
+@Tag(LargeTests.TAG)
+public class TestBackupOfflineRS extends TestBackupBase {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(TestBackupOfflineRS.class);
+
+  @BeforeAll
+  public static void setUp() throws Exception {
+    TEST_UTIL = new HBaseTestingUtil();
+    conf1 = TEST_UTIL.getConfiguration();
+    conf1.setInt("hbase.regionserver.info.port", -1);
+    conf1.setInt(HMaster.HBASE_MASTER_CLEANER_INTERVAL, Integer.MAX_VALUE);
+    autoRestoreOnFailure = true;
+    useSecondCluster = false;
+    setUpHelper();
+    TEST_UTIL.getMiniHBaseCluster().startRegionServer();
+    TEST_UTIL.waitTableAvailable(table1);
+  }
+
+  private static Runnable afterSnapshotHook;
+
+  /** Full backup client that runs {@link #afterSnapshotHook} once the table 
snapshots exist. */
+  public static class FullTableBackupClientWithHook extends 
FullTableBackupClient {
+    @Override
+    protected void snapshotCopy(BackupInfo backupInfo) throws IOException {
+      afterSnapshotHook.run();
+      super.snapshotCopy(backupInfo);
+    }
+  }
+
+  private static void stopRegionServerAndWait(ServerName serverName) throws 
Exception {
+    SingleProcessHBaseCluster cluster = TEST_UTIL.getMiniHBaseCluster();
+    HMaster master = cluster.getMaster();
+    cluster.stopRegionServer(serverName);
+    cluster.waitForRegionServerToStop(serverName, 60_000);
+    TEST_UTIL.waitFor(60_000,
+      () -> 
master.getProcedures().stream().filter(ServerCrashProcedure.class::isInstance)
+        .map(ServerCrashProcedure.class::cast)
+        .anyMatch(scp -> scp.getServerName().equals(serverName) && 
scp.isFinished()));
+    TEST_UTIL.waitUntilNoRegionsInTransition(60_000);
+  }
+
+  private static void restore(String backupId, TableName sourceTable, 
TableName restoredTable)
+    throws Exception {
+    try (Connection conn = ConnectionFactory.createConnection(conf1);
+      BackupAdminImpl backupAdmin = new BackupAdminImpl(conn)) {
+      backupAdmin.restore(BackupUtils.createRestoreRequest(BACKUP_ROOT_DIR, 
backupId, false,
+        new TableName[] { sourceTable }, new TableName[] { restoredTable }, 
true));
+    }
+  }
+
+  private static void restoreAndAssertAllRowsPresent(String backupId, String 
restoredTableName,
+    String message) throws Exception {
+    TableName restoredTable = TableName.valueOf(restoredTableName);
+    restore(backupId, table1, restoredTable);
+    assertEquals(TEST_UTIL.countRows(table1), 
TEST_UTIL.countRows(restoredTable), message);
+  }
+
+  private static Long boundary(BackupSystemTable sysTable, String host) throws 
IOException {
+    return sysTable.readLogTimestampMap(BACKUP_ROOT_DIR).get(table1).get(host);
+  }
+
+  private static void moveRegionAndWait(TableName table, HRegionServer 
destination)
+    throws Exception {
+    try (Admin admin = TEST_UTIL.getConnection().getAdmin()) {
+      RegionInfo region = admin.getRegions(table).get(0);
+      admin.move(region.getEncodedNameAsBytes(), destination.getServerName());
+    }
+    TEST_UTIL.waitFor(60_000, () -> !destination.getRegions(table).isEmpty());
+    TEST_UTIL.waitUntilAllRegionsAssigned(table);
+  }
+
+  /**
+   * Tests that when a full backup is taken while an RS is offline (with WALs 
in oldlogs), the
+   * offline host's timestamps are recorded so subsequent incremental backups 
don't reinclude those
+   * WALs.
+   */
+  @Test
+  public void testBackupWithOfflineRS() throws Exception {
+    LOG.info("Starting testBackupWithOfflineRS");
+
+    SingleProcessHBaseCluster cluster = TEST_UTIL.getMiniHBaseCluster();
+    List<TableName> tables = Lists.newArrayList(table1);
+
+    HRegionServer rsBeforeStop = 
cluster.startRegionServerAndWait(10000).getRegionServer();
+    moveRegionAndWait(table1, rsBeforeStop);
+
+    LOG.info("Inserting data to generate WAL entries");
+    try (Connection conn = ConnectionFactory.createConnection(conf1)) {
+      insertIntoTable(conn, table1, famName, 2, 100);
+    }
+
+    String offlineHost =
+      rsBeforeStop.getServerName().getHostname() + ":" + 
rsBeforeStop.getServerName().getPort();
+    LOG.info("Stopping RS: {}", offlineHost);
+
+    stopRegionServerAndWait(rsBeforeStop.getServerName());
+
+    LOG.info("Taking full backup (with offline RS WALs in oldlogs)");
+    String fullBackupId = fullTableBackup(tables);
+    assertTrue(checkSucceeded(fullBackupId), "Full backup should succeed");
+
+    try (BackupSystemTable sysTable = new 
BackupSystemTable(TEST_UTIL.getConnection())) {
+      Map<TableName, Map<String, Long>> timestamps = 
sysTable.readLogTimestampMap(BACKUP_ROOT_DIR);
+      Map<String, Long> rsTimestamps = timestamps.get(table1);
+      LOG.info("RS timestamps after full backup: {}", rsTimestamps);
+
+      Long tsAfterFullBackup = rsTimestamps.get(offlineHost);
+      assertNotNull(tsAfterFullBackup,
+        "Offline host should have timestamp recorded in trslm after full 
backup");
+
+      LOG.info("Taking incremental backup (should NOT include offline RS 
WALs)");
+      String incrBackupId = incrementalTableBackup(tables);
+      assertTrue(checkSucceeded(incrBackupId), "Incremental backup should 
succeed");
+
+      assertEquals(tsAfterFullBackup, boundary(sysTable, offlineHost),
+        "Incremental backup moved the boundary of the offline host");
+    }
+  }
+
+  /**
+   * Tests that WALs written to an RS after a full backup are correctly 
included in the subsequent
+   * incremental backup, even if that RS has gone offline before the 
incremental runs.
+   */
+  @Test
+  public void testRSGoesOfflineAfterFullBackupBeforeIncremental() throws 
Exception {
+    SingleProcessHBaseCluster cluster = TEST_UTIL.getMiniHBaseCluster();
+    List<TableName> tables = Lists.newArrayList(table1);
+
+    HRegionServer rsBeforeStop = 
cluster.startRegionServerAndWait(10000).getRegionServer();
+
+    try (Connection conn = ConnectionFactory.createConnection(conf1)) {
+      insertIntoTable(conn, table1, famName, 3, 50);
+    }
+
+    String fullBackupId = fullTableBackup(tables);
+    assertTrue(checkSucceeded(fullBackupId), "Full backup should succeed");
+
+    moveRegionAndWait(table1, rsBeforeStop);
+    try (Connection conn = ConnectionFactory.createConnection(conf1)) {
+      insertIntoTable(conn, table1, famName, 4, 50);
+    }
+
+    rsBeforeStop.getWalRoller().requestRollAll();
+    rsBeforeStop.getWalRoller().waitUntilWalRollFinished();
+    String offlineHost =
+      rsBeforeStop.getServerName().getHostname() + ":" + 
rsBeforeStop.getServerName().getPort();
+
+    stopRegionServerAndWait(rsBeforeStop.getServerName());
+
+    String incrBackupId = incrementalTableBackup(tables);
+    assertTrue(checkSucceeded(incrBackupId), "Incremental backup should 
succeed");
+
+    restoreAndAssertAllRowsPresent(incrBackupId, 
"table1_rs_offline_after_full_backup",
+      "Restored table should contain the rows written to the RS that went 
offline");
+
+    try (BackupSystemTable sysTable = new 
BackupSystemTable(TEST_UTIL.getConnection())) {
+      Long boundaryAfterIncr1 = boundary(sysTable, offlineHost);
+      assertNotNull(boundaryAfterIncr1,
+        "Offline RS should have a timestamp boundary after the incremental 
backed up its WALs");
+      Long staleLogRoll =

Review Comment:
   add:
   
   ```
         assertFalse(walsOfHost(sysTable, incrBackupId, offlineHost).isEmpty(),
           "First incremental after the RS went offline should include its 
WALs");
   ```



##########
hbase-backup/src/main/java/org/apache/hadoop/hbase/backup/impl/IncrementalBackupManager.java:
##########
@@ -217,18 +236,14 @@ private List<String> getLogFilesForNewBackup(Map<String, 
Long> olderTimestamps,
         resultLogFiles.add(currentLogFile);
       }
 
-      // It is possible that a host in .oldlogs is an obsolete region server
-      // so newestTimestamps.get(host) here can be null.
-      // Even if these logs belong to a obsolete region server, we still need
-      // to include they to avoid loss of edits for backup.
       Long newTimestamp = newestTimestamps.get(host);
-      if (newTimestamp == null || currentLogTS > newTimestamp) {
+      if (newTimestamp != null && currentLogTS > newTimestamp) {
         newestLogs.add(currentLogFile);
       }
     }
     // remove newest log per host because they are still in use

Review Comment:
   ```
       // Held-back logs are newer than the roll result of their host, or 
belong to a host that did not
       // take part in the log roll. A later backup includes them.
   ```



##########
hbase-backup/src/main/java/org/apache/hadoop/hbase/backup/impl/IncrementalBackupManager.java:
##########


Review Comment:
   Needs updating:
   
   ```
           // A host without a roll result did not take part in this backup's 
log roll: it is offline
           // (for example a dead server whose logs are still being split), or 
it started after the
           // roll. Its logs are held back, like logs newer than the roll 
result of a host that took
           // part. BackupUtils#computeLogBoundaries keeps the boundary of the 
host below held-back
           // logs, so a later backup includes them.
           Long ts = newestTimestamps.get(host);
           if (ts == null) {
             LOG.debug("Holding back log {} of host {}, which did not take part 
in the log roll",
               log.getPath(), host);
           }
   ```



##########
hbase-backup/src/main/java/org/apache/hadoop/hbase/backup/util/BackupUtils.java:
##########
@@ -206,6 +208,84 @@ public static String parseHostNameFromLogFile(Path p) {
     }
   }
 
+  /**
+   * Returns the log roll result of every region server that took part in the 
log roll that happened
+   * between reading {@code previousLogRolls} and {@code latestLogRolls}. A 
region server that did
+   * not take part (for example because it is offline) keeps its old roll 
result, which is never
+   * removed, so it is recognized by its roll result not changing.
+   * @param previousLogRolls roll results by host, read before the log roll
+   * @param latestLogRolls   roll results by host, read after the log roll
+   * @return roll results by host, for the hosts that took part in the log roll
+   */
+  public static Map<String, Long> getRolledHosts(Map<String, Long> 
previousLogRolls,
+    Map<String, Long> latestLogRolls) {
+    return latestLogRolls.entrySet().stream()
+      .filter(entry -> 
!entry.getValue().equals(previousLogRolls.get(entry.getKey())))
+      .collect(Collectors.toMap(Entry::getKey, Entry::getValue));
+  }
+
+  /**
+   * Computes the per-host log boundaries stored by a backup that has no 
previous boundaries to
+   * carry forward, such as a full backup. See
+   * {@link #computeLogBoundaries(Map, Map, Set, Collection, Collection)}.
+   */
+  public static Map<String, Long> computeLogBoundaries(Map<String, Long> 
rolledHosts,
+    Collection<String> coveredLogs, Collection<String> pendingLogs) throws 
IOException {
+    return computeLogBoundaries(rolledHosts, Collections.emptyMap(), 
Collections.emptySet(),
+      coveredLogs, pendingLogs);
+  }
+
+  /**
+   * Computes the per-host log boundaries stored by a backup. Everything up to 
a host's boundary is
+   * covered by backups, so later incremental backups only include that host's 
logs that are newer.
+   * <ul>
+   * <li>A host that took part in the backup's log roll gets its roll 
result.</li>
+   * <li>Any other host gets the creation time of its newest covered log, 
capped to just below its
+   * oldest pending log, so that pending logs are included in a later 
backup.</li>
+   * <li>A host without covered or pending logs keeps its previous boundary 
while it still has logs,
+   * and otherwise gets no boundary.</li>
+   * </ul>
+   * Capping is needed because a host's logs can still be pending while some 
of its newer logs are
+   * already covered, for example when a dead server's logs are split and 
archived out of order. A
+   * host is also kept while any of its logs are pending, even if none are 
covered, because without
+   * a boundary the next backup would treat it as unknown and skip its older 
logs. For the same
+   * reason a host keeps its previous boundary while it still has logs: 
without it, the next backup
+   * would include its logs that are already covered, which can bring back 
deleted data.
+   * @param rolledHosts        roll results of the hosts that took part in the 
log roll
+   * @param previousBoundaries boundaries stored by the previous backup
+   * @param hostsWithLogs      hosts that still have logs, whether or not this 
backup includes them
+   * @param coveredLogs        logs whose edits are covered by this backup
+   * @param pendingLogs        logs whose edits might not be covered by this 
backup

Review Comment:
   The "might not be" was a bit confusing for me. Suggestion:
   
   ```
      * @param coveredLogs        logs whose edits are included in the backup
      * @param pendingLogs        logs whose edits are not to be included in 
the backup
   ```



##########
hbase-backup/src/test/java/org/apache/hadoop/hbase/backup/TestBackupOfflineRS.java:
##########
@@ -0,0 +1,439 @@
+/*
+ * 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.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import org.apache.hadoop.hbase.HBaseTestingUtil;
+import org.apache.hadoop.hbase.ServerName;
+import org.apache.hadoop.hbase.SingleProcessHBaseCluster;
+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.impl.FullTableBackupClient;
+import org.apache.hadoop.hbase.backup.impl.TableBackupClient;
+import org.apache.hadoop.hbase.backup.util.BackupUtils;
+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.client.Delete;
+import org.apache.hadoop.hbase.client.Get;
+import org.apache.hadoop.hbase.client.Put;
+import org.apache.hadoop.hbase.client.RegionInfo;
+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.master.HMaster;
+import org.apache.hadoop.hbase.master.procedure.ServerCrashProcedure;
+import org.apache.hadoop.hbase.regionserver.HRegion;
+import org.apache.hadoop.hbase.regionserver.HRegionServer;
+import org.apache.hadoop.hbase.testclassification.LargeTests;
+import org.apache.hadoop.hbase.util.Bytes;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.hbase.thirdparty.com.google.common.collect.Lists;
+
+/**
+ * Tests that WAL files from offline/inactive RegionServers are handled 
correctly during backup.
+ * Specifically verifies that WALs from an offline RS are:
+ * <ol>
+ * <li>Backed up once in the first backup after the RS goes offline</li>
+ * <li>NOT re-backed up in subsequent backups</li>
+ * </ol>
+ */
+@Tag(LargeTests.TAG)
+public class TestBackupOfflineRS extends TestBackupBase {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(TestBackupOfflineRS.class);
+
+  @BeforeAll
+  public static void setUp() throws Exception {
+    TEST_UTIL = new HBaseTestingUtil();
+    conf1 = TEST_UTIL.getConfiguration();
+    conf1.setInt("hbase.regionserver.info.port", -1);
+    conf1.setInt(HMaster.HBASE_MASTER_CLEANER_INTERVAL, Integer.MAX_VALUE);
+    autoRestoreOnFailure = true;
+    useSecondCluster = false;
+    setUpHelper();
+    TEST_UTIL.getMiniHBaseCluster().startRegionServer();
+    TEST_UTIL.waitTableAvailable(table1);
+  }
+
+  private static Runnable afterSnapshotHook;
+
+  /** Full backup client that runs {@link #afterSnapshotHook} once the table 
snapshots exist. */
+  public static class FullTableBackupClientWithHook extends 
FullTableBackupClient {
+    @Override
+    protected void snapshotCopy(BackupInfo backupInfo) throws IOException {
+      afterSnapshotHook.run();
+      super.snapshotCopy(backupInfo);
+    }
+  }
+
+  private static void stopRegionServerAndWait(ServerName serverName) throws 
Exception {
+    SingleProcessHBaseCluster cluster = TEST_UTIL.getMiniHBaseCluster();
+    HMaster master = cluster.getMaster();
+    cluster.stopRegionServer(serverName);
+    cluster.waitForRegionServerToStop(serverName, 60_000);
+    TEST_UTIL.waitFor(60_000,
+      () -> 
master.getProcedures().stream().filter(ServerCrashProcedure.class::isInstance)
+        .map(ServerCrashProcedure.class::cast)
+        .anyMatch(scp -> scp.getServerName().equals(serverName) && 
scp.isFinished()));
+    TEST_UTIL.waitUntilNoRegionsInTransition(60_000);
+  }
+
+  private static void restore(String backupId, TableName sourceTable, 
TableName restoredTable)
+    throws Exception {
+    try (Connection conn = ConnectionFactory.createConnection(conf1);
+      BackupAdminImpl backupAdmin = new BackupAdminImpl(conn)) {
+      backupAdmin.restore(BackupUtils.createRestoreRequest(BACKUP_ROOT_DIR, 
backupId, false,
+        new TableName[] { sourceTable }, new TableName[] { restoredTable }, 
true));
+    }
+  }
+
+  private static void restoreAndAssertAllRowsPresent(String backupId, String 
restoredTableName,
+    String message) throws Exception {
+    TableName restoredTable = TableName.valueOf(restoredTableName);
+    restore(backupId, table1, restoredTable);
+    assertEquals(TEST_UTIL.countRows(table1), 
TEST_UTIL.countRows(restoredTable), message);
+  }
+
+  private static Long boundary(BackupSystemTable sysTable, String host) throws 
IOException {
+    return sysTable.readLogTimestampMap(BACKUP_ROOT_DIR).get(table1).get(host);
+  }
+
+  private static void moveRegionAndWait(TableName table, HRegionServer 
destination)
+    throws Exception {
+    try (Admin admin = TEST_UTIL.getConnection().getAdmin()) {
+      RegionInfo region = admin.getRegions(table).get(0);
+      admin.move(region.getEncodedNameAsBytes(), destination.getServerName());
+    }
+    TEST_UTIL.waitFor(60_000, () -> !destination.getRegions(table).isEmpty());
+    TEST_UTIL.waitUntilAllRegionsAssigned(table);
+  }
+
+  /**
+   * Tests that when a full backup is taken while an RS is offline (with WALs 
in oldlogs), the
+   * offline host's timestamps are recorded so subsequent incremental backups 
don't reinclude those
+   * WALs.
+   */
+  @Test
+  public void testBackupWithOfflineRS() throws Exception {
+    LOG.info("Starting testBackupWithOfflineRS");
+
+    SingleProcessHBaseCluster cluster = TEST_UTIL.getMiniHBaseCluster();
+    List<TableName> tables = Lists.newArrayList(table1);
+
+    HRegionServer rsBeforeStop = 
cluster.startRegionServerAndWait(10000).getRegionServer();
+    moveRegionAndWait(table1, rsBeforeStop);
+
+    LOG.info("Inserting data to generate WAL entries");
+    try (Connection conn = ConnectionFactory.createConnection(conf1)) {
+      insertIntoTable(conn, table1, famName, 2, 100);
+    }
+
+    String offlineHost =
+      rsBeforeStop.getServerName().getHostname() + ":" + 
rsBeforeStop.getServerName().getPort();
+    LOG.info("Stopping RS: {}", offlineHost);
+
+    stopRegionServerAndWait(rsBeforeStop.getServerName());
+
+    LOG.info("Taking full backup (with offline RS WALs in oldlogs)");
+    String fullBackupId = fullTableBackup(tables);
+    assertTrue(checkSucceeded(fullBackupId), "Full backup should succeed");
+
+    try (BackupSystemTable sysTable = new 
BackupSystemTable(TEST_UTIL.getConnection())) {
+      Map<TableName, Map<String, Long>> timestamps = 
sysTable.readLogTimestampMap(BACKUP_ROOT_DIR);
+      Map<String, Long> rsTimestamps = timestamps.get(table1);
+      LOG.info("RS timestamps after full backup: {}", rsTimestamps);
+
+      Long tsAfterFullBackup = rsTimestamps.get(offlineHost);
+      assertNotNull(tsAfterFullBackup,
+        "Offline host should have timestamp recorded in trslm after full 
backup");
+
+      LOG.info("Taking incremental backup (should NOT include offline RS 
WALs)");
+      String incrBackupId = incrementalTableBackup(tables);
+      assertTrue(checkSucceeded(incrBackupId), "Incremental backup should 
succeed");
+
+      assertEquals(tsAfterFullBackup, boundary(sysTable, offlineHost),
+        "Incremental backup moved the boundary of the offline host");
+    }

Review Comment:
   ```
         List<String> offlineHostWals = walsOfHost(sysTable, incrBackupId, 
offlineHost);
         assertTrue(offlineHostWals.isEmpty(),
           "Incremental backup included WALs of the offline host that the full 
backup covers: "
             + offlineHostWals);
   ```
   
   with `walsOfHost`:
   ```
     private static List<String> walsOfHost(BackupSystemTable sysTable, String 
backupId, String host)
       throws IOException {
       List<String> wals = 
sysTable.readBackupInfo(backupId).getIncrBackupFileList();
       if (wals == null) {
         return List.of();
       }
       return wals.stream()
         .filter(wal -> host.equals(BackupUtils.parseHostNameFromLogFile(new 
Path(wal)))).toList();
     }
   ```



##########
hbase-backup/src/test/java/org/apache/hadoop/hbase/backup/TestBackupOfflineRS.java:
##########
@@ -0,0 +1,439 @@
+/*
+ * 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.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import org.apache.hadoop.hbase.HBaseTestingUtil;
+import org.apache.hadoop.hbase.ServerName;
+import org.apache.hadoop.hbase.SingleProcessHBaseCluster;
+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.impl.FullTableBackupClient;
+import org.apache.hadoop.hbase.backup.impl.TableBackupClient;
+import org.apache.hadoop.hbase.backup.util.BackupUtils;
+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.client.Delete;
+import org.apache.hadoop.hbase.client.Get;
+import org.apache.hadoop.hbase.client.Put;
+import org.apache.hadoop.hbase.client.RegionInfo;
+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.master.HMaster;
+import org.apache.hadoop.hbase.master.procedure.ServerCrashProcedure;
+import org.apache.hadoop.hbase.regionserver.HRegion;
+import org.apache.hadoop.hbase.regionserver.HRegionServer;
+import org.apache.hadoop.hbase.testclassification.LargeTests;
+import org.apache.hadoop.hbase.util.Bytes;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import org.apache.hbase.thirdparty.com.google.common.collect.Lists;
+
+/**
+ * Tests that WAL files from offline/inactive RegionServers are handled 
correctly during backup.
+ * Specifically verifies that WALs from an offline RS are:
+ * <ol>
+ * <li>Backed up once in the first backup after the RS goes offline</li>
+ * <li>NOT re-backed up in subsequent backups</li>
+ * </ol>
+ */
+@Tag(LargeTests.TAG)
+public class TestBackupOfflineRS extends TestBackupBase {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(TestBackupOfflineRS.class);
+
+  @BeforeAll
+  public static void setUp() throws Exception {
+    TEST_UTIL = new HBaseTestingUtil();
+    conf1 = TEST_UTIL.getConfiguration();
+    conf1.setInt("hbase.regionserver.info.port", -1);
+    conf1.setInt(HMaster.HBASE_MASTER_CLEANER_INTERVAL, Integer.MAX_VALUE);
+    autoRestoreOnFailure = true;
+    useSecondCluster = false;
+    setUpHelper();
+    TEST_UTIL.getMiniHBaseCluster().startRegionServer();
+    TEST_UTIL.waitTableAvailable(table1);
+  }
+
+  private static Runnable afterSnapshotHook;
+
+  /** Full backup client that runs {@link #afterSnapshotHook} once the table 
snapshots exist. */
+  public static class FullTableBackupClientWithHook extends 
FullTableBackupClient {
+    @Override
+    protected void snapshotCopy(BackupInfo backupInfo) throws IOException {
+      afterSnapshotHook.run();
+      super.snapshotCopy(backupInfo);
+    }
+  }
+
+  private static void stopRegionServerAndWait(ServerName serverName) throws 
Exception {
+    SingleProcessHBaseCluster cluster = TEST_UTIL.getMiniHBaseCluster();
+    HMaster master = cluster.getMaster();
+    cluster.stopRegionServer(serverName);
+    cluster.waitForRegionServerToStop(serverName, 60_000);
+    TEST_UTIL.waitFor(60_000,
+      () -> 
master.getProcedures().stream().filter(ServerCrashProcedure.class::isInstance)
+        .map(ServerCrashProcedure.class::cast)
+        .anyMatch(scp -> scp.getServerName().equals(serverName) && 
scp.isFinished()));
+    TEST_UTIL.waitUntilNoRegionsInTransition(60_000);
+  }
+
+  private static void restore(String backupId, TableName sourceTable, 
TableName restoredTable)
+    throws Exception {
+    try (Connection conn = ConnectionFactory.createConnection(conf1);
+      BackupAdminImpl backupAdmin = new BackupAdminImpl(conn)) {
+      backupAdmin.restore(BackupUtils.createRestoreRequest(BACKUP_ROOT_DIR, 
backupId, false,
+        new TableName[] { sourceTable }, new TableName[] { restoredTable }, 
true));
+    }
+  }
+
+  private static void restoreAndAssertAllRowsPresent(String backupId, String 
restoredTableName,
+    String message) throws Exception {
+    TableName restoredTable = TableName.valueOf(restoredTableName);
+    restore(backupId, table1, restoredTable);
+    assertEquals(TEST_UTIL.countRows(table1), 
TEST_UTIL.countRows(restoredTable), message);
+  }
+
+  private static Long boundary(BackupSystemTable sysTable, String host) throws 
IOException {
+    return sysTable.readLogTimestampMap(BACKUP_ROOT_DIR).get(table1).get(host);
+  }
+
+  private static void moveRegionAndWait(TableName table, HRegionServer 
destination)
+    throws Exception {
+    try (Admin admin = TEST_UTIL.getConnection().getAdmin()) {
+      RegionInfo region = admin.getRegions(table).get(0);
+      admin.move(region.getEncodedNameAsBytes(), destination.getServerName());
+    }
+    TEST_UTIL.waitFor(60_000, () -> !destination.getRegions(table).isEmpty());
+    TEST_UTIL.waitUntilAllRegionsAssigned(table);
+  }
+
+  /**
+   * Tests that when a full backup is taken while an RS is offline (with WALs 
in oldlogs), the
+   * offline host's timestamps are recorded so subsequent incremental backups 
don't reinclude those
+   * WALs.
+   */
+  @Test
+  public void testBackupWithOfflineRS() throws Exception {
+    LOG.info("Starting testBackupWithOfflineRS");
+
+    SingleProcessHBaseCluster cluster = TEST_UTIL.getMiniHBaseCluster();
+    List<TableName> tables = Lists.newArrayList(table1);
+
+    HRegionServer rsBeforeStop = 
cluster.startRegionServerAndWait(10000).getRegionServer();
+    moveRegionAndWait(table1, rsBeforeStop);
+
+    LOG.info("Inserting data to generate WAL entries");
+    try (Connection conn = ConnectionFactory.createConnection(conf1)) {
+      insertIntoTable(conn, table1, famName, 2, 100);
+    }
+
+    String offlineHost =
+      rsBeforeStop.getServerName().getHostname() + ":" + 
rsBeforeStop.getServerName().getPort();
+    LOG.info("Stopping RS: {}", offlineHost);
+
+    stopRegionServerAndWait(rsBeforeStop.getServerName());
+
+    LOG.info("Taking full backup (with offline RS WALs in oldlogs)");
+    String fullBackupId = fullTableBackup(tables);
+    assertTrue(checkSucceeded(fullBackupId), "Full backup should succeed");
+
+    try (BackupSystemTable sysTable = new 
BackupSystemTable(TEST_UTIL.getConnection())) {
+      Map<TableName, Map<String, Long>> timestamps = 
sysTable.readLogTimestampMap(BACKUP_ROOT_DIR);
+      Map<String, Long> rsTimestamps = timestamps.get(table1);
+      LOG.info("RS timestamps after full backup: {}", rsTimestamps);
+
+      Long tsAfterFullBackup = rsTimestamps.get(offlineHost);
+      assertNotNull(tsAfterFullBackup,
+        "Offline host should have timestamp recorded in trslm after full 
backup");
+
+      LOG.info("Taking incremental backup (should NOT include offline RS 
WALs)");
+      String incrBackupId = incrementalTableBackup(tables);
+      assertTrue(checkSucceeded(incrBackupId), "Incremental backup should 
succeed");
+
+      assertEquals(tsAfterFullBackup, boundary(sysTable, offlineHost),
+        "Incremental backup moved the boundary of the offline host");
+    }
+  }
+
+  /**
+   * Tests that WALs written to an RS after a full backup are correctly 
included in the subsequent
+   * incremental backup, even if that RS has gone offline before the 
incremental runs.
+   */
+  @Test
+  public void testRSGoesOfflineAfterFullBackupBeforeIncremental() throws 
Exception {
+    SingleProcessHBaseCluster cluster = TEST_UTIL.getMiniHBaseCluster();
+    List<TableName> tables = Lists.newArrayList(table1);
+
+    HRegionServer rsBeforeStop = 
cluster.startRegionServerAndWait(10000).getRegionServer();
+
+    try (Connection conn = ConnectionFactory.createConnection(conf1)) {
+      insertIntoTable(conn, table1, famName, 3, 50);
+    }
+
+    String fullBackupId = fullTableBackup(tables);
+    assertTrue(checkSucceeded(fullBackupId), "Full backup should succeed");
+
+    moveRegionAndWait(table1, rsBeforeStop);
+    try (Connection conn = ConnectionFactory.createConnection(conf1)) {
+      insertIntoTable(conn, table1, famName, 4, 50);
+    }
+
+    rsBeforeStop.getWalRoller().requestRollAll();
+    rsBeforeStop.getWalRoller().waitUntilWalRollFinished();
+    String offlineHost =
+      rsBeforeStop.getServerName().getHostname() + ":" + 
rsBeforeStop.getServerName().getPort();
+
+    stopRegionServerAndWait(rsBeforeStop.getServerName());
+
+    String incrBackupId = incrementalTableBackup(tables);
+    assertTrue(checkSucceeded(incrBackupId), "Incremental backup should 
succeed");
+
+    restoreAndAssertAllRowsPresent(incrBackupId, 
"table1_rs_offline_after_full_backup",
+      "Restored table should contain the rows written to the RS that went 
offline");
+
+    try (BackupSystemTable sysTable = new 
BackupSystemTable(TEST_UTIL.getConnection())) {
+      Long boundaryAfterIncr1 = boundary(sysTable, offlineHost);
+      assertNotNull(boundaryAfterIncr1,
+        "Offline RS should have a timestamp boundary after the incremental 
backed up its WALs");
+      Long staleLogRoll =
+        
sysTable.readRegionServerLastLogRollResult(BACKUP_ROOT_DIR).get(offlineHost);
+
+      String incrBackupId2 = incrementalTableBackup(tables);
+      assertTrue(checkSucceeded(incrBackupId2), "Second incremental backup 
should succeed");
+      Long boundaryAfterIncr2 = boundary(sysTable, offlineHost);
+
+      String incrBackupId3 = incrementalTableBackup(tables);
+      assertTrue(checkSucceeded(incrBackupId3), "Third incremental backup 
should succeed");
+      Long boundaryAfterIncr3 = boundary(sysTable, offlineHost);
+
+      String incrBackupId4 = incrementalTableBackup(tables);
+      assertTrue(checkSucceeded(incrBackupId4), "Fourth incremental backup 
should succeed");
+      Long boundaryAfterIncr4 = boundary(sysTable, offlineHost);
+
+      String message = "Incremental backup moved the boundary of the offline 
RS, which would back "

Review Comment:
   ```
         String message = "Incremental backup moved the boundary of the offline 
RS, which would back "
           + "up its WALs again (last log roll = " + staleLogRoll + ")";
         List<String> walsBackedUpAgain = new ArrayList<>();
         for (String laterBackupId : List.of(incrBackupId2, incrBackupId3, 
incrBackupId4)) {
           walsBackedUpAgain.addAll(walsOfHost(sysTable, laterBackupId, 
offlineHost));
         }
         assertAll(() -> assertEquals(boundaryAfterIncr1, boundaryAfterIncr2, 
message),
           () -> assertEquals(boundaryAfterIncr1, boundaryAfterIncr3, message),
           () -> assertEquals(boundaryAfterIncr1, boundaryAfterIncr4, message),
           () -> assertTrue(walsBackedUpAgain.isEmpty(),
             "Later incremental backups included WALs of the offline RS again: 
" + walsBackedUpAgain));
       }
   ```



##########
hbase-backup/src/main/java/org/apache/hadoop/hbase/backup/impl/FullTableBackupClient.java:
##########
@@ -248,6 +263,75 @@ private void performSnapshots(Admin admin) throws 
IOException {
     }
   }
 
+  /**
+   * Computes the per-host log boundaries stored by this full backup, using
+   * {@link BackupUtils#computeLogBoundaries}. A host that took part in this 
backup's log roll gets
+   * its roll result: WALs created after the roll are included in the next 
incremental backup, even
+   * though some of their edits may already be in the snapshot, which is safe 
because deletes are
+   * replayed along with the puts. For any other host, its archived WALs and 
the WALs of dead
+   * servers are covered by the snapshot, so the next incremental backup does 
not replay them. The
+   * WALs of a live server that did not take part in the roll (for example one 
that started during
+   * it) are pending, because they can still receive edits that are not in the 
snapshot. The live
+   * servers are read only after the WAL directories are listed: a region 
server creates its WAL
+   * directory only after the master registers it, so every live server whose 
directory was listed
+   * is found. If the live servers do not include every host that took part in 
the roll, the
+   * master's server list is incomplete (for example right after a master 
failover), and the backup
+   * fails instead of treating live servers as dead. It must run right after 
the log roll, before
+   * the snapshot is taken, so that a host starting later gets no boundary and 
has all of its WALs
+   * included in the next incremental backup.
+   */
+  @RestrictedApi(
+      explanation = "Package-private for test visibility only. Do not use 
outside tests.",
+      link = "",
+      allowedOnPath = 
"(.*/src/test/.*|.*/org/apache/hadoop/hbase/backup/impl/FullTableBackupClient.java)")
+  static Map<String, Long> computeLogBoundaries(FileSystem fs, Path walRootDir,
+    Map<String, Long> rolledHosts, Admin admin) throws IOException {
+    Path logDir = new Path(walRootDir, HConstants.HREGION_LOGDIR_NAME);
+    Path oldLogDir = new Path(walRootDir, HConstants.HREGION_OLDLOGDIR_NAME);
+
+    Map<ServerName, List<String>> logsByServer = new HashMap<>();
+    for (FileStatus serverLogDir : fs.listStatus(logDir)) {
+      ServerName serverName =
+        
AbstractFSWALProvider.getServerNameFromWALDirectoryName(serverLogDir.getPath());
+      if (serverName == null) {
+        continue;
+      }
+      List<String> logs = logsByServer.computeIfAbsent(serverName, k -> new 
ArrayList<>());
+      for (FileStatus log : fs.listStatus(serverLogDir.getPath())) {
+        if (!AbstractFSWALProvider.isMetaFile(log.getPath())) {
+          logs.add(log.getPath().toString());
+        }
+      }
+    }
+
+    Set<ServerName> live = new HashSet<>(admin.getRegionServers());
+    Set<String> liveAddresses =
+      live.stream().map(sn -> 
sn.getAddress().toString()).collect(Collectors.toSet());
+    if (!liveAddresses.containsAll(rolledHosts.keySet())) {
+      throw new IOException("Live region servers " + live + " do not include 
every host that took"
+        + " part in this backup's log roll " + rolledHosts.keySet() + ". The 
master's server list"
+        + " may be incomplete, for example during a master failover, so the 
backup is failed to be"

Review Comment:
   `for example during a master failover, or a region server stopped after the 
log roll, so the backup is failed to be retried.`



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to