Skip to content

HBASE-29776: Log filtering in IncrementalBackupManager can lead to data loss - #8694

Merged
hgromer merged 1 commit into
apache:masterfrom
hgromer:HBASE-29776
Oct 15, 2026
Merged

hgromer merged 1 commit into
apache:masterfrom
hgromer:HBASE-29776

Conversation

@hgromer

@hgromer hgromer commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

There was a previous attempt at this change here. I want to make sure the dicussions there are carried over

// 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);

@hgromer hgromer Sep 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know there was debate whether this was necessary or not here, and want to emphasize that this is a very important change. Without this change, we're subject to data loss anytime a new RS joins the cluster.

If we add just this code snippet back, our TestBackupOfflineRS tests will fail, showing this is in fact problematic. Importantly, adding a file to newestLog means it is not included in the backup. The roll time moves forward, and then the log cleaner deletes these logs without them ever being included in the backup.

There was some discussion about modifying this check due to hypothetical scenarios. I think it's a lot safer to simply remove this block which can cause data loss and provides no value. Simply removing this check keeps things safe. And I don't think the scenario here warrants potential data loss. Even if this scenario did play out, the only consequence is that we include a WAL that we don't technically need in a backup. This is a non-issue, given that our restore process handles this without any issues.

Finally, there was dicussion around trying to ensure "cross-table consistency" and data being out of sync which I disagree with as well. This backup is a point-in-time snapshot. In a distributed system, each RS is going to have it's own point in time. This is similarly true for snapshots, where one region's snapshot procedure can happen minutes prior to another one.

Regardless, I think preventing data loss scenarios and simplifying the code should be the priority here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still argue to not delete this check, but to change it to if (newTimestamp != null && currentLogTS > newTimestamp) instead. I think the removal (as suggested in the PR) can lead to data loss. This adjusted version fixes the original data loss issue and does not break the newly added tests.

Two things have to occur on a region server during an incremental backup to cause data loss:

  1. Two quick wall rolls. The backup's own roll creates a new WAL, C. Before the backup finishes listing oldWALs, the region server has to roll twice more: C → T, then T → U. Only then is T closed and eligible for archiving.
  2. T is archived while C isn't. cleanOldLogs archives any closed WAL whose regions have all flushed past its edits. If C holds edits for a region that T doesn't touch, and that region hasn't flushed yet, T gets archived first.

Of these 2 conditions, I expect (2) to occur commonly. For (1): The window runs from the backup's roll to the end of the oldWALs listing, and that's normally a few seconds. Two rolls might fit in it when one of these holds:

  • Heavy ingest. By default a WAL rolls when it reaches 0.5 × (2 × HDFS block size), which is about 128 MB. A region server ingesting around 50–100 MB/s rolls every 1–3 seconds. The hourly time-based roll (hbase.regionserver.logroll.period) is far too slow to matter.
  • A slow listing. getLogFilesForNewBackup runs one listStatus per region server directory, then a recursive listing of oldWALs. BackupLogCleaner keeps every WAL that a backup root still needs, so with backups enabled oldWALs can hold tens or hundreds of thousands of files. A large cluster or a slow NameNode can stretch the window to tens of seconds.

A rare case, but one that can occur on any incremental backup on any region server. For a large cluster with daily backups, it is bound to happen eventually. The result is a WAL that never gets included in a backup, leading to data loss when restoring.

I agree that simpler is better, and I wished lots of the backup code was simpler, but in this case the complexity is worth it.

Example test case (breaks on the current PR, succeeds on the current PR + suggested adjustment):

// paste me in TestIncrementalBackupManager 

  /**
   * WALs can be archived out of order. This test sets up the WAL directories as an incremental
   * backup would find them if, after its log roll, a newer WAL got archived while an older one was
   * still in the WALs directory. The older WAL must still end up in a backup.
   */
  @Test
  public void testOutOfOrderArchivedWALDoesNotSkipOlderWAL() throws Exception {
    List<TableName> tables = List.of(table1);
    HRegionServer rs = TEST_UTIL.getMiniHBaseCluster().getRegionServer(0);
    ServerName serverName = rs.getServerName();
    Path walRootDir = CommonFSUtils.getWALRootDir(conf1);
    FileSystem fs = walRootDir.getFileSystem(conf1);

    try (Connection conn = ConnectionFactory.createConnection(conf1);
      BackupAdminImpl backupAdmin = new BackupAdminImpl(conn)) {
      String fullBackupId =
        backupAdmin.backupTables(createBackupRequest(BackupType.FULL, tables, BACKUP_ROOT_DIR));
      assertTrue(checkSucceeded(fullBackupId));

      // Both test WALs are newer than any WAL that exists at this point, so they are newer than the
      // WAL reported by the log roll of the next incremental backup.
      long olderWALTs = EnvironmentEdgeManager.currentTime() + 1;
      long newerWALTs = olderWALTs + 1;
      Path walDir =
        new Path(walRootDir, AbstractFSWALProvider.getWALDirectoryName(serverName.toString()));
      Path olderWAL =
        new Path(walDir, serverName.toString() + BackupUtils.LOGNAME_SEPARATOR + olderWALTs);
      Path archiveDir = new Path(walRootDir,
        AbstractFSWALProvider.getWALArchiveDirectoryName(conf1, serverName.toString()));
      Path newerArchivedWAL =
        new Path(archiveDir, "wal" + BackupUtils.LOGNAME_SEPARATOR + newerWALTs);
      fs.create(olderWAL).close();
      fs.mkdirs(archiveDir);
      fs.create(newerArchivedWAL).close();

      try {
        List<String> firstBackupFiles;
        try (IncrementalBackupManager manager = new IncrementalBackupManager(conn, conf1)) {
          BackupInfo backupInfo = manager.createBackupInfo("backup_incr_1",
            BackupType.INCREMENTAL, tables, BACKUP_ROOT_DIR, -1, -1, false, false);
          Map<String, Long> boundaries = manager.getIncrBackupLogFileMap();
          manager.writeRegionServerLogTimestamp(backupInfo.getTables(), boundaries);
          firstBackupFiles = backupInfo.getIncrBackupFileList();
        }

        // Roll once more, so the log roll of the next backup reports a WAL that is newer than both
        // test WALs.
        TEST_UTIL.waitFor(30_000, () -> EnvironmentEdgeManager.currentTime() > newerWALTs);
        rs.getWalRoller().requestRollAll();
        rs.getWalRoller().waitUntilWalRollFinished();

        List<String> secondBackupFiles;
        try (IncrementalBackupManager manager = new IncrementalBackupManager(conn, conf1)) {
          BackupInfo backupInfo = manager.createBackupInfo("backup_incr_2",
            BackupType.INCREMENTAL, tables, BACKUP_ROOT_DIR, -1, -1, false, false);
          manager.getIncrBackupLogFileMap();
          secondBackupFiles = backupInfo.getIncrBackupFileList();
        }

        assertTrue(
          firstBackupFiles.contains(olderWAL.toString())
            || secondBackupFiles.contains(olderWAL.toString()),
          "WAL " + olderWAL + " was not included in any backup. First backup: " + firstBackupFiles
            + ", second backup: " + secondBackupFiles);
      } finally {
        fs.delete(olderWAL, false);
        fs.delete(newerArchivedWAL, false);
      }
    }
  }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, okay I think I see your point, I didn't realize that WAL archival was random. This comment makes sense. In that case, I think your proposed change makes sense, while still preventing the data loss scenario that I'm trying to address

// After this checkpoint, even if entering cancel process, will let the backup finished
backupInfo.setState(BackupState.COMPLETE);

adjustTimestampsForOfflineHosts(previousLogRollsByHost, latestLogRollsByHost);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new WAL-directory scan is invoked after backupInfo.setState(BackupState.COMPLETE) — past the point the surrounding comment describes as the checkpoint after which "even if entering cancel process, will let the backup finished". Any IOException from the scan (fs.listStatus(oldLogDir) throws FileNotFoundException when oldWALs has not been created yet, e.g. a freshly initialized WAL root) or an unchecked parse failure now propagates out of handleNonContinuousBackup before updateBackupMetadata() runs, leaving a completed snapshot with no persisted timestamp map.

Fix: Either move the scan before the COMPLETE checkpoint, or wrap it so a scan failure logs a warning and falls back to the log-roll-derived newTimestamps instead of failing the backup after the snapshot has been taken. Also guard against a non-existent oldLogDir with an fs.exists check.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, good idea. I've wrapped it in a try/catch

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've moved when this happens, and will actually let this throw now, because it's early enough a failure won't mess up the state. Falling back to existing newTimestamps exposes the data loss bugs, so I think a loud failure is warranted

@hgromer

hgromer commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

I believe the yetus failures are unrelated

@charlesconnell charlesconnell left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel good about this since it's been battle-tested inside Hubspot

* newTimestamps. This ensures subsequent incremental backups won't reinclude WALs already covered
* by this full backup's snapshot.
*/
private void adjustTimestampsForOfflineHosts(Map<String, Long> previousLogRollsByHost,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this change introduces a new possible data loss scenario, where a region server comes online after the log roll and before the WAL listing (ie: while the performBackupSnapshots is running the MR copy jobs, which can significant for full backups).

This test demonstrates the issue:

public class TestBackupOfflineRS extends TestBackupBase {
...
  /** 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);
    }
  }

  /**
   * Tests that edits written during a full backup, after its log roll and table snapshot, to an RS
   * that started after that log roll, are included in the next incremental backup.
   */
  @Test
  public void testRSStartedDuringFullBackup() throws Exception {
    SingleProcessHBaseCluster cluster = TEST_UTIL.getMiniHBaseCluster();
    List<TableName> tables = Lists.newArrayList(table1);

    afterSnapshotHook = () -> {
      try {
        HRegionServer newRS = cluster.startRegionServerAndWait(10000).getRegionServer();
        try (Admin admin = TEST_UTIL.getConnection().getAdmin()) {
          RegionInfo region = admin.getRegions(table1).get(0);
          admin.move(region.getEncodedNameAsBytes(), newRS.getServerName());
          TEST_UTIL.waitUntilAllRegionsAssigned(table1);
        }
        try (Connection conn = ConnectionFactory.createConnection(conf1)) {
          insertIntoTable(conn, table1, famName, 8, 50).close();
        }
      } catch (Exception e) {
        throw new RuntimeException(e);
      }
    };
    conf1.set(TableBackupClient.BACKUP_CLIENT_IMPL_CLASS,
      FullTableBackupClientWithHook.class.getName());
    String fullBackupId;
    try {
      fullBackupId = fullTableBackup(tables);
    } finally {
      conf1.unset(TableBackupClient.BACKUP_CLIENT_IMPL_CLASS);
    }
    assertTrue(checkSucceeded(fullBackupId), "Full backup should succeed");

    String incrBackupId = incrementalTableBackup(tables);
    assertTrue(checkSucceeded(incrBackupId), "Incremental backup should succeed");

    TableName restoredTable = TableName.valueOf("table1_rs_started_during_full_backup");
    try (Connection conn = ConnectionFactory.createConnection(conf1);
      BackupAdminImpl backupAdmin = new BackupAdminImpl(conn)) {
      backupAdmin.restore(BackupUtils.createRestoreRequest(BACKUP_ROOT_DIR, incrBackupId, false,
        new TableName[] { table1 }, new TableName[] { restoredTable }, true));
    }
    assertEquals(TEST_UTIL.countRows(table1), TEST_UTIL.countRows(restoredTable),
      "Restored table should contain all rows, including those written to the new RS");
  }

This test succeeded on the original version.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've moved adjustTimestampsForOfflineHosts prior to the snapshotting of the table.

* </ol>
*/
@Tag(LargeTests.TAG)
public class TestBackupOfflineRS extends TestBackupBase {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests fail on my computer, and I traced it down to my hostname containing a capital. Apparently, the backup code is mixing lower and original-cased hostnames.

  • Lowercased: the server-name host,port,startcode, and WAL folder/file names.
  • Original case: ServerName.getHostname() and getAddress().toString(), which end up in the roll results.

So on a host with uppercase letters in its name, roll-result keys and WAL-path keys don't match.

The simplest option is to lowercase the hostname wherever the backup code builds a host:port key: in logRollV2, in LogRollBackupSubprocedure, and in the two BackupUtils parsers, so all of them agree with the WAL file names. Already stored rslogts and trslm rows would need to be read case-insensitively, or migrated once.

My workaround (which can be adjusted to trigger the issue too):

  conf1.set("hbase.unsafe.regionserver.hostname", "localhost");
  conf1.set("hbase.master.hostname", "localhost");

Fine for me to log this as a separate issue instead of fixing here. Let me know if you'd prefer I log a new ticket for this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mind drafting up a new issue here? It'd be nice to try to keep the scope of this PR, which is already quite large, to what it is now.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String incrBackupId2 = incrementalTableBackup(tables);
assertTrue(checkSucceeded(incrBackupId2), "Second incremental backup should succeed");

timestamps = sysTable.readLogTimestampMap(BACKUP_ROOT_DIR);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The offline host gets re-introduced upon later incremental backups.

In the IncrementalBackupManager, this is the check:

  Long earliestTimestampToIncludeInBackup = previousTimestampMins.get(host);
  boolean isInactive = earliestTimestampToIncludeInBackup != null
    && earliestTimestampToIncludeInBackup >= latestLogRoll;

So a host counts as inactive only if two things are true:

  1. The previous backup stored a boundary for it.
  2. That boundary has caught up with its last roll result.

Any host that the previous backup did not store is treated as active, whatever its roll result says.

Replace the remaining code by this snippet as demonstration (this will make the test fail):

      timestamps = sysTable.readLogTimestampMap(BACKUP_ROOT_DIR);
      rsTimestamps = timestamps.get(table1);
      assertFalse(rsTimestamps.containsKey(offlineHost),
        "Offline RS should not have a boundary after all its WALs have been backed up");

      // The offline RS keeps its entry in the last-log-roll table, which is never pruned. Later
      // incrementals must neither restore its boundary nor back up its WALs a second time.
      Long staleLogRoll =
        sysTable.readRegionServerLastLogRollResult(BACKUP_ROOT_DIR).get(offlineHost);

      String incrBackupId3 = incrementalTableBackup(tables);
      assertTrue(checkSucceeded(incrBackupId3), "Third incremental backup should succeed");
      Long boundaryAfterIncr3 = sysTable.readLogTimestampMap(BACKUP_ROOT_DIR).get(table1)
        .get(offlineHost);

      String incrBackupId4 = incrementalTableBackup(tables);
      assertTrue(checkSucceeded(incrBackupId4), "Fourth incremental backup should succeed");
      List<String> reincludedWals = walsOfHost(sysTable.readBackupInfo(incrBackupId4), offlineHost);

      assertAll(
        () -> assertNull(boundaryAfterIncr3,
          "Third incremental restored the pruned boundary of the offline RS (last log roll = "
            + staleLogRoll + ")"),
        () -> assertTrue(reincludedWals.isEmpty(),
          "Fourth incremental backed up WALs of the offline RS again: " + reincludedWals
            + ", first backed up in " + incrBackupId + ": " + offlineHostWals));
    }
  }

  private static List<String> walsOfHost(BackupInfo backupInfo, String host) {
    List<String> wals = backupInfo.getIncrBackupFileList();
    if (wals == null) {
      return List.of();
    }
    return wals.stream()
      .filter(wal -> host.equals(BackupUtils.parseHostNameFromLogFile(new Path(wal)))).toList();
  }

A consequence is that old WALs can be backed up again, which can corrupt data when doing a recovery.

Possible fix, use the same defintion as FullBackupTableClient (but didn't verify this):

  Map<String, Long> rollsBefore = readRegionServerLastLogRollResult();
  BackupUtils.logRoll(conn, backupInfo.getBackupRootDir(), conf);
  Map<String, Long> rollsAfter = readRegionServerLastLogRollResult();
  // active: rollsAfter.get(h) != null && !rollsAfter.get(h).equals(rollsBefore.get(h))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, yeah good callout, will do

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests don't demonstrate the actual data loss scenario from the ticket, just the changed bookkeeping.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've updated the tests to actually run backups + restores and assert that data wasn't lost. These new tests fail without the current change set

// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still argue to not delete this check, but to change it to if (newTimestamp != null && currentLogTS > newTimestamp) instead. I think the removal (as suggested in the PR) can lead to data loss. This adjusted version fixes the original data loss issue and does not break the newly added tests.

Two things have to occur on a region server during an incremental backup to cause data loss:

  1. Two quick wall rolls. The backup's own roll creates a new WAL, C. Before the backup finishes listing oldWALs, the region server has to roll twice more: C → T, then T → U. Only then is T closed and eligible for archiving.
  2. T is archived while C isn't. cleanOldLogs archives any closed WAL whose regions have all flushed past its edits. If C holds edits for a region that T doesn't touch, and that region hasn't flushed yet, T gets archived first.

Of these 2 conditions, I expect (2) to occur commonly. For (1): The window runs from the backup's roll to the end of the oldWALs listing, and that's normally a few seconds. Two rolls might fit in it when one of these holds:

  • Heavy ingest. By default a WAL rolls when it reaches 0.5 × (2 × HDFS block size), which is about 128 MB. A region server ingesting around 50–100 MB/s rolls every 1–3 seconds. The hourly time-based roll (hbase.regionserver.logroll.period) is far too slow to matter.
  • A slow listing. getLogFilesForNewBackup runs one listStatus per region server directory, then a recursive listing of oldWALs. BackupLogCleaner keeps every WAL that a backup root still needs, so with backups enabled oldWALs can hold tens or hundreds of thousands of files. A large cluster or a slow NameNode can stretch the window to tens of seconds.

A rare case, but one that can occur on any incremental backup on any region server. For a large cluster with daily backups, it is bound to happen eventually. The result is a WAL that never gets included in a backup, leading to data loss when restoring.

I agree that simpler is better, and I wished lots of the backup code was simpler, but in this case the complexity is worth it.

Example test case (breaks on the current PR, succeeds on the current PR + suggested adjustment):

// paste me in TestIncrementalBackupManager 

  /**
   * WALs can be archived out of order. This test sets up the WAL directories as an incremental
   * backup would find them if, after its log roll, a newer WAL got archived while an older one was
   * still in the WALs directory. The older WAL must still end up in a backup.
   */
  @Test
  public void testOutOfOrderArchivedWALDoesNotSkipOlderWAL() throws Exception {
    List<TableName> tables = List.of(table1);
    HRegionServer rs = TEST_UTIL.getMiniHBaseCluster().getRegionServer(0);
    ServerName serverName = rs.getServerName();
    Path walRootDir = CommonFSUtils.getWALRootDir(conf1);
    FileSystem fs = walRootDir.getFileSystem(conf1);

    try (Connection conn = ConnectionFactory.createConnection(conf1);
      BackupAdminImpl backupAdmin = new BackupAdminImpl(conn)) {
      String fullBackupId =
        backupAdmin.backupTables(createBackupRequest(BackupType.FULL, tables, BACKUP_ROOT_DIR));
      assertTrue(checkSucceeded(fullBackupId));

      // Both test WALs are newer than any WAL that exists at this point, so they are newer than the
      // WAL reported by the log roll of the next incremental backup.
      long olderWALTs = EnvironmentEdgeManager.currentTime() + 1;
      long newerWALTs = olderWALTs + 1;
      Path walDir =
        new Path(walRootDir, AbstractFSWALProvider.getWALDirectoryName(serverName.toString()));
      Path olderWAL =
        new Path(walDir, serverName.toString() + BackupUtils.LOGNAME_SEPARATOR + olderWALTs);
      Path archiveDir = new Path(walRootDir,
        AbstractFSWALProvider.getWALArchiveDirectoryName(conf1, serverName.toString()));
      Path newerArchivedWAL =
        new Path(archiveDir, "wal" + BackupUtils.LOGNAME_SEPARATOR + newerWALTs);
      fs.create(olderWAL).close();
      fs.mkdirs(archiveDir);
      fs.create(newerArchivedWAL).close();

      try {
        List<String> firstBackupFiles;
        try (IncrementalBackupManager manager = new IncrementalBackupManager(conn, conf1)) {
          BackupInfo backupInfo = manager.createBackupInfo("backup_incr_1",
            BackupType.INCREMENTAL, tables, BACKUP_ROOT_DIR, -1, -1, false, false);
          Map<String, Long> boundaries = manager.getIncrBackupLogFileMap();
          manager.writeRegionServerLogTimestamp(backupInfo.getTables(), boundaries);
          firstBackupFiles = backupInfo.getIncrBackupFileList();
        }

        // Roll once more, so the log roll of the next backup reports a WAL that is newer than both
        // test WALs.
        TEST_UTIL.waitFor(30_000, () -> EnvironmentEdgeManager.currentTime() > newerWALTs);
        rs.getWalRoller().requestRollAll();
        rs.getWalRoller().waitUntilWalRollFinished();

        List<String> secondBackupFiles;
        try (IncrementalBackupManager manager = new IncrementalBackupManager(conn, conf1)) {
          BackupInfo backupInfo = manager.createBackupInfo("backup_incr_2",
            BackupType.INCREMENTAL, tables, BACKUP_ROOT_DIR, -1, -1, false, false);
          manager.getIncrBackupLogFileMap();
          secondBackupFiles = backupInfo.getIncrBackupFileList();
        }

        assertTrue(
          firstBackupFiles.contains(olderWAL.toString())
            || secondBackupFiles.contains(olderWAL.toString()),
          "WAL " + olderWAL + " was not included in any backup. First backup: " + firstBackupFiles
            + ", second backup: " + secondBackupFiles);
      } finally {
        fs.delete(olderWAL, false);
        fs.delete(newerArchivedWAL, false);
      }
    }
  }

@hgromer

hgromer commented Oct 3, 2026

Copy link
Copy Markdown
Contributor Author

@DieterDePaepe I believe I've addressed all your comments, thanks for being so thorough, let me know what you think

* @throws IOException exception
*/
private List<String> getLogFilesForNewBackup(Map<String, Long> olderTimestamps,
private LogFileSelection getLogFilesForNewBackup(Map<String, Long> olderTimestamps,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a server crashes as we're doing log rolls + backups, its WALs will sit in the /WALs directory until that split is finished. We need to have a concept of WALs that were "held back", which are set as LogFileSelection#pending.

We will keep the boundary at before the oldest held back WAL, ensuring that the log cleaner doesn't delete it. These WALs will be backed up in the next incremental.

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())) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sadly, I can't think of a much better way to accomplish consistency here. The HMaster may be initializing when we query for live RS, and unfortunately that means we may get a partial list of RS back.

This sanity check is a little brittle, but I believe that it's robust enough to do what we need. Given that we just rolled WAL files, if we don't see any of those hosts in the live RS list we got back from the HMaster, we can likely assume the HMaster didn't return a comprehensive list, and we should abort.

@DieterDP-ng

Copy link
Copy Markdown
Contributor

@DieterDePaepe I believe I've addressed all your comments, thanks for being so thorough, let me know what you think

I'll try to take a look first half of the coming week. I was as a conference this week.

@DieterDePaepe DieterDePaepe left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a few minor javadoc suggestions and test additions.

Very nice work with this PR!

* 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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...

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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
        }

newestLogs.add(currentLogFile);
}
}
// remove newest log per host because they are still in use

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

    // 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.


assertEquals(tsAfterFullBackup, boundary(sysTable, offlineHost),
"Incremental backup moved the boundary of the offline host");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

      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();
  }

Long boundaryAfterIncr1 = boundary(sysTable, offlineHost);
assertNotNull(boundaryAfterIncr1,
"Offline RS should have a timestamp boundary after the incremental backed up its WALs");
Long staleLogRoll =

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add:

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

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 "

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

      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));
    }

}

/**
* Computes the per-host log boundaries stored by this full backup, using

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)?

* @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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@hgromer
hgromer merged commit 09d4006 into apache:master Oct 15, 2026
15 of 20 checks passed
@hgromer
hgromer deleted the HBASE-29776 branch October 15, 2026 22:39
hgromer pushed a commit to hgromer/hbase that referenced this pull request Oct 15, 2026
hgromer added a commit to hgromer/hbase that referenced this pull request Oct 15, 2026
hgromer added a commit that referenced this pull request Oct 15, 2026
…a loss (#8694) (#8778)

Co-authored-by: Hernan Gelaf-Romer <hgelafromer@hubspot.com>
hgromer added a commit that referenced this pull request Oct 15, 2026
…a loss (#8694) (#8777)

Co-authored-by: Hernan Gelaf-Romer <hgelafromer@hubspot.com>
hgromer added a commit that referenced this pull request Oct 16, 2026
…a loss (#8694) (#8776)

Co-authored-by: Hernan Gelaf-Romer <hgelafromer@hubspot.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants