001/*
002 * Licensed to the Apache Software Foundation (ASF) under one
003 * or more contributor license agreements.  See the NOTICE file
004 * distributed with this work for additional information
005 * regarding copyright ownership.  The ASF licenses this file
006 * to you under the Apache License, Version 2.0 (the
007 * "License"); you may not use this file except in compliance
008 * with the License.  You may obtain a copy of the License at
009 *
010 *     http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package org.apache.hadoop.hbase.backup.replication;
019
020import com.google.errorprone.annotations.RestrictedApi;
021import java.io.FileNotFoundException;
022import java.io.IOException;
023import java.io.UncheckedIOException;
024import java.util.List;
025import java.util.Map;
026import java.util.UUID;
027import java.util.concurrent.ConcurrentHashMap;
028import java.util.concurrent.TimeUnit;
029import java.util.concurrent.locks.ReentrantLock;
030import java.util.stream.Collectors;
031import org.apache.hadoop.conf.Configuration;
032import org.apache.hadoop.fs.FileStatus;
033import org.apache.hadoop.fs.FileSystem;
034import org.apache.hadoop.fs.FileUtil;
035import org.apache.hadoop.fs.Path;
036import org.apache.hadoop.hbase.HBaseConfiguration;
037import org.apache.hadoop.hbase.HConstants;
038import org.apache.hadoop.hbase.backup.impl.BackupSystemTable;
039import org.apache.hadoop.hbase.backup.util.BackupFileSystemManager;
040import org.apache.hadoop.hbase.backup.util.BackupUtils;
041import org.apache.hadoop.hbase.backup.util.BulkLoadProcessor;
042import org.apache.hadoop.hbase.client.Connection;
043import org.apache.hadoop.hbase.client.ConnectionFactory;
044import org.apache.hadoop.hbase.io.asyncfs.monitor.StreamSlowMonitor;
045import org.apache.hadoop.hbase.regionserver.wal.WALUtil;
046import org.apache.hadoop.hbase.replication.BaseReplicationEndpoint;
047import org.apache.hadoop.hbase.replication.regionserver.ReplicationSourceInterface;
048import org.apache.hadoop.hbase.util.CommonFSUtils;
049import org.apache.hadoop.hbase.util.EnvironmentEdgeManager;
050import org.apache.hadoop.hbase.wal.FSHLogProvider;
051import org.apache.hadoop.hbase.wal.WAL;
052import org.apache.yetus.audience.InterfaceAudience;
053import org.slf4j.Logger;
054import org.slf4j.LoggerFactory;
055
056/**
057 * ContinuousBackupReplicationEndpoint is responsible for replicating WAL entries to a backup
058 * storage. It organizes WAL entries by day and periodically flushes the data, ensuring that WAL
059 * files do not exceed the configured size. The class includes mechanisms for handling the WAL
060 * files, performing bulk load backups, and ensuring that the replication process is safe.
061 */
062@InterfaceAudience.Private
063public class ContinuousBackupReplicationEndpoint extends BaseReplicationEndpoint {
064  private static final Logger LOG =
065    LoggerFactory.getLogger(ContinuousBackupReplicationEndpoint.class);
066  public static final String CONF_PEER_UUID = "hbase.backup.wal.replication.peerUUID";
067  public static final String CONF_BACKUP_ROOT_DIR = "hbase.backup.root.dir";
068
069  private final Map<Long, FSHLogProvider.Writer> walWriters = new ConcurrentHashMap<>();
070  private final ReentrantLock lock = new ReentrantLock();
071
072  private ReplicationSourceInterface replicationSource;
073  private Configuration conf;
074  private BackupFileSystemManager backupFileSystemManager;
075  private UUID peerUUID;
076  private String peerId;
077
078  private long latestWALEntryTimestamp = -1L;
079
080  public static final long ONE_DAY_IN_MILLISECONDS = TimeUnit.DAYS.toMillis(1);
081  public static final String WAL_FILE_PREFIX = "wal_file.";
082
083  @Override
084  public void init(Context context) throws IOException {
085    super.init(context);
086    this.replicationSource = context.getReplicationSource();
087    this.peerId = context.getPeerId();
088    this.conf = HBaseConfiguration.create(context.getConfiguration());
089
090    initializePeerUUID();
091    initializeBackupFileSystemManager();
092    LOG.info("{} Initialization complete", Utils.logPeerId(peerId));
093  }
094
095  private void initializePeerUUID() throws IOException {
096    String peerUUIDStr = conf.get(CONF_PEER_UUID);
097    if (peerUUIDStr == null || peerUUIDStr.isEmpty()) {
098      throw new IOException("Peer UUID is not specified. Please configure " + CONF_PEER_UUID);
099    }
100    try {
101      this.peerUUID = UUID.fromString(peerUUIDStr);
102      LOG.info("{} Peer UUID initialized to {}", Utils.logPeerId(peerId), peerUUID);
103    } catch (IllegalArgumentException e) {
104      throw new IOException("Invalid Peer UUID format: " + peerUUIDStr, e);
105    }
106  }
107
108  private void initializeBackupFileSystemManager() throws IOException {
109    String backupRootDir = conf.get(CONF_BACKUP_ROOT_DIR);
110    if (backupRootDir == null || backupRootDir.isEmpty()) {
111      throw new IOException(
112        "Backup root directory is not specified. Configure " + CONF_BACKUP_ROOT_DIR);
113    }
114
115    try {
116      this.backupFileSystemManager = new BackupFileSystemManager(peerId, conf, backupRootDir);
117      LOG.info("{} BackupFileSystemManager initialized successfully for {}",
118        Utils.logPeerId(peerId), backupRootDir);
119    } catch (IOException e) {
120      throw new IOException("Failed to initialize BackupFileSystemManager", e);
121    }
122  }
123
124  private void flushWriters() throws IOException {
125    LOG.info("{} Flushing {} WAL writers", Utils.logPeerId(peerId), walWriters.size());
126    for (Map.Entry<Long, FSHLogProvider.Writer> entry : walWriters.entrySet()) {
127      FSHLogProvider.Writer writer = entry.getValue();
128      if (writer != null) {
129        LOG.debug("{} Closing WAL writer for day: {}", Utils.logPeerId(peerId), entry.getKey());
130        try {
131          writer.close();
132          LOG.debug("{} Successfully closed WAL writer for day: {}", Utils.logPeerId(peerId),
133            entry.getKey());
134        } catch (IOException e) {
135          LOG.error("{} Failed to close WAL writer for day: {}. Error: {}", Utils.logPeerId(peerId),
136            entry.getKey(), e.getMessage(), e);
137          throw e;
138        }
139      }
140    }
141    walWriters.clear();
142
143    // All received WAL entries have been flushed and persisted successfully.
144    // At this point, it's safe to record the latest replicated timestamp,
145    // as we are guaranteed that all entries up to that timestamp are durably stored.
146    // This checkpoint is essential for enabling consistent Point-in-Time Restore (PITR).
147    updateLastReplicatedTimestampForContinuousBackup();
148
149    LOG.info("{} WAL writers flushed and cleared", Utils.logPeerId(peerId));
150  }
151
152  @Override
153  public UUID getPeerUUID() {
154    return peerUUID;
155  }
156
157  @Override
158  public void start() {
159    LOG.info("{} Starting ContinuousBackupReplicationEndpoint", Utils.logPeerId(peerId));
160    startAsync();
161  }
162
163  @Override
164  protected void doStart() {
165    LOG.info("{} ContinuousBackupReplicationEndpoint started successfully.",
166      Utils.logPeerId(peerId));
167    notifyStarted();
168  }
169
170  @Override
171  public boolean replicate(ReplicateContext replicateContext) {
172    final List<WAL.Entry> entries = replicateContext.getEntries();
173    if (entries.isEmpty()) {
174      LOG.debug("{} No WAL entries to replicate", Utils.logPeerId(peerId));
175      return false;
176    }
177
178    LOG.debug("{} Received {} WAL entries for replication", Utils.logPeerId(peerId),
179      entries.size());
180
181    Map<Long, List<WAL.Entry>> groupedEntries = groupEntriesByDay(entries);
182    LOG.debug("{} Grouped WAL entries by day: {}", Utils.logPeerId(peerId),
183      groupedEntries.keySet());
184
185    lock.lock();
186    try {
187      for (Map.Entry<Long, List<WAL.Entry>> entry : groupedEntries.entrySet()) {
188        LOG.debug("{} Backing up {} WAL entries for day {}", Utils.logPeerId(peerId),
189          entry.getValue().size(), entry.getKey());
190        backupWalEntries(entry.getKey(), entry.getValue());
191      }
192
193      // Capture the timestamp of the last WAL entry processed. This is used as the replication
194      // checkpoint so that point-in-time restores know the latest consistent time up to which
195      // replication has
196      // occurred.
197      latestWALEntryTimestamp = entries.get(entries.size() - 1).getKey().getWriteTime();
198
199      LOG.debug("{} Replication submitted successfully", Utils.logPeerId(peerId));
200      return true;
201    } catch (IOException e) {
202      LOG.error("{} Replication failed. Error details: {}", Utils.logPeerId(peerId), e.getMessage(),
203        e);
204      return false;
205    } finally {
206      lock.unlock();
207    }
208  }
209
210  /**
211   * Persists the latest replicated WAL entry timestamp in the backup system table. This checkpoint
212   * is critical for Continuous Backup and Point-in-Time Restore (PITR) to ensure restore operations
213   * only go up to a known safe point. The value is stored per region server using its ServerName as
214   * the key.
215   * @throws IOException if the checkpoint update fails
216   */
217  private void updateLastReplicatedTimestampForContinuousBackup() throws IOException {
218    try (final Connection conn = ConnectionFactory.createConnection(conf);
219      BackupSystemTable backupSystemTable = new BackupSystemTable(conn)) {
220      backupSystemTable.updateBackupCheckpointTimestamp(replicationSource.getServerWALsBelongTo(),
221        latestWALEntryTimestamp);
222    }
223  }
224
225  private Map<Long, List<WAL.Entry>> groupEntriesByDay(List<WAL.Entry> entries) {
226    return entries.stream().collect(
227      Collectors.groupingBy(entry -> (entry.getKey().getWriteTime() / ONE_DAY_IN_MILLISECONDS)
228        * ONE_DAY_IN_MILLISECONDS));
229  }
230
231  private void backupWalEntries(long day, List<WAL.Entry> walEntries) throws IOException {
232    LOG.debug("{} Starting backup of {} WAL entries for day {}", Utils.logPeerId(peerId),
233      walEntries.size(), day);
234
235    try {
236      FSHLogProvider.Writer walWriter = walWriters.computeIfAbsent(day, this::createWalWriter);
237
238      for (WAL.Entry entry : walEntries) {
239        walWriter.append(entry);
240      }
241
242      walWriter.sync(true);
243    } catch (UncheckedIOException e) {
244      String errorMsg = Utils.logPeerId(peerId) + " Failed to get or create WAL Writer for " + day;
245      LOG.error("{} Backup failed for day {}. Error: {}", Utils.logPeerId(peerId), day,
246        e.getMessage(), e);
247      throw new IOException(errorMsg, e);
248    }
249
250    List<Path> bulkLoadFiles = BulkLoadProcessor.processBulkLoadFiles(walEntries);
251
252    if (LOG.isTraceEnabled()) {
253      LOG.trace("{} Processed {} bulk load files for WAL entries", Utils.logPeerId(peerId),
254        bulkLoadFiles.size());
255      LOG.trace("{} Bulk load files: {}", Utils.logPeerId(peerId),
256        bulkLoadFiles.stream().map(Path::toString).collect(Collectors.joining(", ")));
257    }
258
259    uploadBulkLoadFiles(day, bulkLoadFiles);
260  }
261
262  private FSHLogProvider.Writer createWalWriter(long dayInMillis) {
263    String dayDirectoryName = BackupUtils.formatToDateString(dayInMillis);
264
265    FileSystem fs = backupFileSystemManager.getBackupFs();
266    Path walsDir = backupFileSystemManager.getWalsDir();
267
268    try {
269      // Create a directory for the day
270      Path dayDir = new Path(walsDir, dayDirectoryName);
271      fs.mkdirs(dayDir);
272
273      // Generate a unique WAL file name
274      long currentTime = EnvironmentEdgeManager.getDelegate().currentTime();
275      String walFileName = WAL_FILE_PREFIX + currentTime + "." + UUID.randomUUID();
276      Path walFilePath = new Path(dayDir, walFileName);
277
278      // Initialize the WAL writer
279      FSHLogProvider.Writer writer =
280        ObjectStoreProtobufWalWriter.class.getDeclaredConstructor().newInstance();
281      writer.init(fs, walFilePath, conf, true, WALUtil.getWALBlockSize(conf, fs, walFilePath),
282        StreamSlowMonitor.create(conf, walFileName));
283
284      LOG.info("{} WAL writer created: {}", Utils.logPeerId(peerId), walFilePath);
285      return writer;
286    } catch (Exception e) {
287      throw new UncheckedIOException(
288        Utils.logPeerId(peerId) + " Failed to initialize WAL Writer for day: " + dayDirectoryName,
289        new IOException(e));
290    }
291  }
292
293  @Override
294  public void stop() {
295    LOG.info("{} Stopping ContinuousBackupReplicationEndpoint...", Utils.logPeerId(peerId));
296    stopAsync();
297  }
298
299  @Override
300  protected void doStop() {
301    close();
302    LOG.info("{} ContinuousBackupReplicationEndpoint stopped successfully.",
303      Utils.logPeerId(peerId));
304    notifyStopped();
305  }
306
307  private void close() {
308    LOG.info("{} Closing WAL replication component...", Utils.logPeerId(peerId));
309    lock.lock();
310    try {
311      flushWriters();
312    } catch (IOException e) {
313      LOG.error("{} Failed to Flush Open Wal Writers: {}", Utils.logPeerId(peerId), e.getMessage(),
314        e);
315    } finally {
316      lock.unlock();
317      LOG.info("{} WAL replication component closed.", Utils.logPeerId(peerId));
318    }
319  }
320
321  @RestrictedApi(
322      explanation = "Package-private for test visibility only. Do not use outside tests.",
323      link = "",
324      allowedOnPath = "(.*/src/test/.*|.*/org/apache/hadoop/hbase/backup/replication/ContinuousBackupReplicationEndpoint.java)")
325  void uploadBulkLoadFiles(long dayInMillis, List<Path> bulkLoadFiles)
326    throws BulkLoadUploadException {
327    if (bulkLoadFiles.isEmpty()) {
328      LOG.debug("{} No bulk load files to upload for {}", Utils.logPeerId(peerId), dayInMillis);
329      return;
330    }
331
332    LOG.debug("{} Starting upload of {} bulk load files", Utils.logPeerId(peerId),
333      bulkLoadFiles.size());
334
335    if (LOG.isTraceEnabled()) {
336      LOG.trace("{} Bulk load files to upload: {}", Utils.logPeerId(peerId),
337        bulkLoadFiles.stream().map(Path::toString).collect(Collectors.joining(", ")));
338    }
339    String dayDirectoryName = BackupUtils.formatToDateString(dayInMillis);
340    Path bulkloadDir = new Path(backupFileSystemManager.getBulkLoadFilesDir(), dayDirectoryName);
341    try {
342      backupFileSystemManager.getBackupFs().mkdirs(bulkloadDir);
343    } catch (IOException e) {
344      throw new BulkLoadUploadException(
345        String.format("%s Failed to create bulkload directory in backupFS: %s",
346          Utils.logPeerId(peerId), bulkloadDir),
347        e);
348    }
349
350    for (Path file : bulkLoadFiles) {
351      Path sourcePath;
352      try {
353        sourcePath = getBulkLoadFileStagingPath(file);
354      } catch (FileNotFoundException fnfe) {
355        throw new BulkLoadUploadException(
356          String.format("%s Bulk load file not found: %s", Utils.logPeerId(peerId), file), fnfe);
357      } catch (IOException ioe) {
358        throw new BulkLoadUploadException(
359          String.format("%s Failed to resolve source path for: %s", Utils.logPeerId(peerId), file),
360          ioe);
361      }
362
363      Path destPath = new Path(bulkloadDir, file);
364
365      try {
366        LOG.debug("{} Copying bulk load file from {} to {}", Utils.logPeerId(peerId), sourcePath,
367          destPath);
368
369        copyWithCleanup(CommonFSUtils.getRootDirFileSystem(conf), sourcePath,
370          backupFileSystemManager.getBackupFs(), destPath, conf);
371
372        LOG.info("{} Bulk load file {} successfully backed up to {}", Utils.logPeerId(peerId), file,
373          destPath);
374      } catch (IOException e) {
375        throw new BulkLoadUploadException(
376          String.format("%s Failed to copy bulk load file %s to %s on day %s",
377            Utils.logPeerId(peerId), file, destPath, BackupUtils.formatToDateString(dayInMillis)),
378          e);
379      }
380    }
381
382    LOG.debug("{} Completed upload of bulk load files", Utils.logPeerId(peerId));
383  }
384
385  /**
386   * Copy a file with cleanup logic in case of failure. Always overwrite destination to avoid
387   * leaving corrupt partial files.
388   */
389  @RestrictedApi(
390      explanation = "Package-private for test visibility only. Do not use outside tests.",
391      link = "",
392      allowedOnPath = "(.*/src/test/.*|.*/org/apache/hadoop/hbase/backup/replication/ContinuousBackupReplicationEndpoint.java)")
393  static void copyWithCleanup(FileSystem srcFS, Path src, FileSystem dstFS, Path dst,
394    Configuration conf) throws IOException {
395    try {
396      if (dstFS.exists(dst)) {
397        FileStatus srcStatus = srcFS.getFileStatus(src);
398        FileStatus dstStatus = dstFS.getFileStatus(dst);
399
400        if (srcStatus.getLen() == dstStatus.getLen()) {
401          LOG.info("Destination file {} already exists with same length ({}). Skipping copy.", dst,
402            dstStatus.getLen());
403          return; // Skip upload
404        } else {
405          LOG.warn(
406            "Destination file {} exists but length differs (src={}, dst={}). " + "Overwriting now.",
407            dst, srcStatus.getLen(), dstStatus.getLen());
408        }
409      }
410
411      // Always overwrite in case previous copy left partial data
412      FileUtil.copy(srcFS, src, dstFS, dst, false, true, conf);
413    } catch (IOException e) {
414      try {
415        if (dstFS.exists(dst)) {
416          dstFS.delete(dst, true);
417          LOG.warn("Deleted partial/corrupt destination file {} after copy failure", dst);
418        }
419      } catch (IOException cleanupEx) {
420        LOG.warn("Failed to cleanup destination file {} after copy failure", dst, cleanupEx);
421      }
422      throw e;
423    }
424  }
425
426  private Path getBulkLoadFileStagingPath(Path relativePathFromNamespace) throws IOException {
427    FileSystem rootFs = CommonFSUtils.getRootDirFileSystem(conf);
428    Path rootDir = CommonFSUtils.getRootDir(conf);
429    Path baseNSDir = new Path(HConstants.BASE_NAMESPACE_DIR);
430    Path baseNamespaceDir = new Path(rootDir, baseNSDir);
431    Path hFileArchiveDir =
432      new Path(rootDir, new Path(HConstants.HFILE_ARCHIVE_DIRECTORY, baseNSDir));
433
434    LOG.debug("{} Searching for bulk load file: {} in paths: {}, {}", Utils.logPeerId(peerId),
435      relativePathFromNamespace, baseNamespaceDir, hFileArchiveDir);
436
437    Path result =
438      findExistingPath(rootFs, baseNamespaceDir, hFileArchiveDir, relativePathFromNamespace);
439    LOG.debug("{} Bulk load file found at {}", Utils.logPeerId(peerId), result);
440    return result;
441  }
442
443  private static Path findExistingPath(FileSystem rootFs, Path baseNamespaceDir,
444    Path hFileArchiveDir, Path filePath) throws IOException {
445    if (LOG.isTraceEnabled()) {
446      LOG.trace("Checking for bulk load file at: {} and {}", new Path(baseNamespaceDir, filePath),
447        new Path(hFileArchiveDir, filePath));
448    }
449
450    for (Path candidate : new Path[] { new Path(baseNamespaceDir, filePath),
451      new Path(hFileArchiveDir, filePath) }) {
452      if (rootFs.exists(candidate)) {
453        return candidate;
454      }
455    }
456
457    throw new FileNotFoundException("Bulk load file not found at either: "
458      + new Path(baseNamespaceDir, filePath) + " or " + new Path(hFileArchiveDir, filePath));
459  }
460
461  @Override
462  public void beforePersistingReplicationOffset() throws IOException {
463    lock.lock();
464    try {
465      flushWriters();
466    } finally {
467      lock.unlock();
468    }
469  }
470}