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.mapreduce;
019
020import static org.apache.hadoop.hbase.mapreduce.HFileOutputFormat2.MULTI_TABLE_HFILEOUTPUTFORMAT_CONF_DEFAULT;
021import static org.apache.hadoop.hbase.mapreduce.HFileOutputFormat2.MULTI_TABLE_HFILEOUTPUTFORMAT_CONF_KEY;
022
023import java.io.IOException;
024import java.text.ParseException;
025import java.text.SimpleDateFormat;
026import java.util.ArrayList;
027import java.util.Collections;
028import java.util.HashSet;
029import java.util.List;
030import java.util.Map;
031import java.util.Set;
032import java.util.TreeMap;
033import org.apache.hadoop.conf.Configuration;
034import org.apache.hadoop.conf.Configured;
035import org.apache.hadoop.fs.Path;
036import org.apache.hadoop.hbase.Cell;
037import org.apache.hadoop.hbase.CellUtil;
038import org.apache.hadoop.hbase.ExtendedCell;
039import org.apache.hadoop.hbase.HBaseConfiguration;
040import org.apache.hadoop.hbase.KeyValue;
041import org.apache.hadoop.hbase.PrivateCellUtil;
042import org.apache.hadoop.hbase.TableName;
043import org.apache.hadoop.hbase.client.Connection;
044import org.apache.hadoop.hbase.client.ConnectionFactory;
045import org.apache.hadoop.hbase.client.Delete;
046import org.apache.hadoop.hbase.client.Mutation;
047import org.apache.hadoop.hbase.client.Put;
048import org.apache.hadoop.hbase.client.RegionLocator;
049import org.apache.hadoop.hbase.client.Table;
050import org.apache.hadoop.hbase.io.ImmutableBytesWritable;
051import org.apache.hadoop.hbase.mapreduce.HFileOutputFormat2.TableInfo;
052import org.apache.hadoop.hbase.regionserver.wal.WALCellCodec;
053import org.apache.hadoop.hbase.snapshot.SnapshotRegionLocator;
054import org.apache.hadoop.hbase.util.Bytes;
055import org.apache.hadoop.hbase.util.EnvironmentEdgeManager;
056import org.apache.hadoop.hbase.util.MapReduceExtendedCell;
057import org.apache.hadoop.hbase.wal.WALEdit;
058import org.apache.hadoop.hbase.wal.WALEditInternalHelper;
059import org.apache.hadoop.hbase.wal.WALKey;
060import org.apache.hadoop.io.WritableComparable;
061import org.apache.hadoop.mapreduce.Job;
062import org.apache.hadoop.mapreduce.Mapper;
063import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
064import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
065import org.apache.hadoop.util.Tool;
066import org.apache.hadoop.util.ToolRunner;
067import org.apache.yetus.audience.InterfaceAudience;
068import org.slf4j.Logger;
069import org.slf4j.LoggerFactory;
070
071/**
072 * A tool to replay WAL files as a M/R job. The WAL can be replayed for a set of tables or all
073 * tables, and a time range can be provided (in milliseconds). The WAL is filtered to the passed set
074 * of tables and the output can optionally be mapped to another set of tables. WAL replay can also
075 * generate HFiles for later bulk importing, in that case the WAL is replayed for a single table
076 * only.
077 */
078@InterfaceAudience.Public
079public class WALPlayer extends Configured implements Tool {
080  private static final Logger LOG = LoggerFactory.getLogger(WALPlayer.class);
081  final static String NAME = "WALPlayer";
082  public final static String BULK_OUTPUT_CONF_KEY = "wal.bulk.output";
083  public final static String TABLES_KEY = "wal.input.tables";
084  public final static String TABLE_MAP_KEY = "wal.input.tablesmap";
085  public final static String INPUT_FILES_SEPARATOR_KEY = "wal.input.separator";
086  public final static String IGNORE_MISSING_FILES = "wal.input.ignore.missing.files";
087  public final static String MULTI_TABLES_SUPPORT = "wal.multi.tables.support";
088
089  /**
090   * Configuration flag that controls how the WALPlayer handles empty input WAL files.
091   * <p>
092   * If set to {@code true}, the WALPlayer will silently ignore empty files that cannot be parsed as
093   * valid WAL files. This is useful in scenarios where such files are expected (e.g., due to
094   * partial writes or cleanup operations).
095   * </p>
096   * <p>
097   * If set to {@code false} (default), the WALPlayer will throw an exception when it encounters an
098   * empty or un-parsable WAL file. This is useful for catching unexpected data issues early.
099   * </p>
100   * <p>
101   * Default value: {@link #DEFAULT_IGNORE_EMPTY_FILES} ({@code false})
102   * </p>
103   */
104  public final static String IGNORE_EMPTY_FILES = "wal.input.ignore.empty.files";
105  public final static boolean DEFAULT_IGNORE_EMPTY_FILES = false;
106
107  protected static final String tableSeparator = ";";
108
109  private final static String JOB_NAME_CONF_KEY = "mapreduce.job.name";
110
111  public WALPlayer() {
112  }
113
114  protected WALPlayer(final Configuration c) {
115    super(c);
116  }
117
118  /**
119   * A mapper that just writes out KeyValues. This one can be used together with
120   * {@link CellSortReducer}
121   */
122  static class WALKeyValueMapper extends Mapper<WALKey, WALEdit, WritableComparable<?>, Cell> {
123    private Set<String> tableSet = new HashSet<String>();
124    private boolean multiTableSupport = false;
125    private boolean diskBasedSortingEnabled = false;
126
127    @Override
128    public void map(WALKey key, WALEdit value, Context context) throws IOException {
129      try {
130        // skip all other tables
131        TableName table = key.getTableName();
132        if (tableSet.contains(table.getNameAsString())) {
133          for (Cell cell : value.getCells()) {
134            if (WALEdit.isMetaEditFamily(cell)) {
135              continue;
136            }
137
138            // Set sequenceId from WALKey, since it is not included by WALCellCodec. The sequenceId
139            // on WALKey is the same value that was on the cells in the WALEdit. This enables
140            // CellSortReducer to use sequenceId to disambiguate duplicate cell timestamps.
141            // See HBASE-27649
142            PrivateCellUtil.setSequenceId(cell, key.getSequenceId());
143
144            byte[] outKey = multiTableSupport
145              ? Bytes.add(table.getName(), Bytes.toBytes(tableSeparator), CellUtil.cloneRow(cell))
146              : CellUtil.cloneRow(cell);
147            ExtendedCell extendedCell = PrivateCellUtil.ensureExtendedCell(cell);
148            context.write(wrapKey(outKey, extendedCell), new MapReduceExtendedCell(extendedCell));
149          }
150        }
151      } catch (InterruptedException e) {
152        LOG.error("Interrupted while emitting Cell", e);
153        Thread.currentThread().interrupt();
154      }
155    }
156
157    @Override
158    public void setup(Context context) throws IOException {
159      Configuration conf = context.getConfiguration();
160      String[] tables = conf.getStrings(TABLES_KEY);
161      this.multiTableSupport = conf.getBoolean(MULTI_TABLES_SUPPORT, false);
162      this.diskBasedSortingEnabled = HFileOutputFormat2.diskBasedSortingEnabled(conf);
163      Collections.addAll(tableSet, tables);
164    }
165
166    private WritableComparable<?> wrapKey(byte[] key, ExtendedCell cell) {
167      if (this.diskBasedSortingEnabled) {
168        // Important to build a new cell with the updated key to maintain multi-table support
169        KeyValue kv = new KeyValue(key, 0, key.length, cell.getFamilyArray(),
170          cell.getFamilyOffset(), cell.getFamilyLength(), cell.getQualifierArray(),
171          cell.getQualifierOffset(), cell.getQualifierLength(), cell.getTimestamp(),
172          KeyValue.Type.codeToType(PrivateCellUtil.getTypeByte(cell)), null, 0, 0);
173        kv.setSequenceId(cell.getSequenceId());
174        return new KeyOnlyCellComparable(kv);
175      } else {
176        return new ImmutableBytesWritable(key);
177      }
178    }
179  }
180
181  /**
182   * Enum for map metrics. Keep it out here rather than inside in the Map inner-class so we can find
183   * associated properties.
184   */
185  protected static enum Counter {
186    /** Number of aggregated writes */
187    PUTS,
188    /** Number of aggregated deletes */
189    DELETES,
190    CELLS_READ,
191    CELLS_WRITTEN,
192    WALEDITS
193  }
194
195  /**
196   * A mapper that writes out {@link Mutation} to be directly applied to a running HBase instance.
197   */
198  protected static class WALMapper
199    extends Mapper<WALKey, WALEdit, ImmutableBytesWritable, Mutation> {
200    private Map<TableName, TableName> tables = new TreeMap<>();
201
202    @Override
203    public void map(WALKey key, WALEdit value, Context context) throws IOException {
204      context.getCounter(Counter.WALEDITS).increment(1);
205      try {
206        if (tables.isEmpty() || tables.containsKey(key.getTableName())) {
207          TableName targetTable =
208            tables.isEmpty() ? key.getTableName() : tables.get(key.getTableName());
209          ImmutableBytesWritable tableOut = new ImmutableBytesWritable(targetTable.getName());
210          Put put = null;
211          Delete del = null;
212          ExtendedCell lastCell = null;
213          for (ExtendedCell cell : WALEditInternalHelper.getExtendedCells(value)) {
214            context.getCounter(Counter.CELLS_READ).increment(1);
215            // Filtering WAL meta marker entries.
216            if (WALEdit.isMetaEditFamily(cell)) {
217              continue;
218            }
219            // Allow a subclass filter out this cell.
220            if (filter(context, cell)) {
221              // A WALEdit may contain multiple operations (HBASE-3584) and/or
222              // multiple rows (HBASE-5229).
223              // Aggregate as much as possible into a single Put/Delete
224              // operation before writing to the context.
225              if (
226                lastCell == null || lastCell.getTypeByte() != cell.getTypeByte()
227                  || !CellUtil.matchingRows(lastCell, cell)
228              ) {
229                // row or type changed, write out aggregate KVs.
230                if (put != null) {
231                  context.write(tableOut, put);
232                  context.getCounter(Counter.PUTS).increment(1);
233                }
234                if (del != null) {
235                  context.write(tableOut, del);
236                  context.getCounter(Counter.DELETES).increment(1);
237                }
238                if (CellUtil.isDelete(cell)) {
239                  del = new Delete(CellUtil.cloneRow(cell));
240                } else {
241                  put = new Put(CellUtil.cloneRow(cell));
242                }
243              }
244              if (CellUtil.isDelete(cell)) {
245                del.add(cell);
246              } else {
247                put.add(cell);
248              }
249              context.getCounter(Counter.CELLS_WRITTEN).increment(1);
250            }
251            lastCell = cell;
252          }
253          // write residual KVs
254          if (put != null) {
255            context.write(tableOut, put);
256            context.getCounter(Counter.PUTS).increment(1);
257          }
258          if (del != null) {
259            context.getCounter(Counter.DELETES).increment(1);
260            context.write(tableOut, del);
261          }
262        }
263      } catch (InterruptedException e) {
264        LOG.error("Interrupted while writing results", e);
265        Thread.currentThread().interrupt();
266      }
267    }
268
269    protected boolean filter(Context context, final Cell cell) {
270      return true;
271    }
272
273    @Override
274    protected void
275      cleanup(Mapper<WALKey, WALEdit, ImmutableBytesWritable, Mutation>.Context context)
276        throws IOException, InterruptedException {
277      super.cleanup(context);
278    }
279
280    @SuppressWarnings("checkstyle:EmptyBlock")
281    @Override
282    public void setup(Context context) throws IOException {
283      String[] tableMap = context.getConfiguration().getStrings(TABLE_MAP_KEY);
284      String[] tablesToUse = context.getConfiguration().getStrings(TABLES_KEY);
285      if (tableMap == null) {
286        tableMap = tablesToUse;
287      }
288      if (tablesToUse == null) {
289        // Then user wants all tables.
290      } else if (tablesToUse.length != tableMap.length) {
291        // this can only happen when WALMapper is used directly by a class other than WALPlayer
292        throw new IOException("Incorrect table mapping specified .");
293      }
294      int i = 0;
295      if (tablesToUse != null) {
296        for (String table : tablesToUse) {
297          tables.put(TableName.valueOf(table), TableName.valueOf(tableMap[i++]));
298        }
299      }
300    }
301  }
302
303  void setupTime(Configuration conf, String option) throws IOException {
304    String val = conf.get(option);
305    if (null == val) {
306      return;
307    }
308    long ms;
309    try {
310      // first try to parse in user friendly form
311      ms = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SS").parse(val).getTime();
312    } catch (ParseException pe) {
313      try {
314        // then see if just a number of ms's was specified
315        ms = Long.parseLong(val);
316      } catch (NumberFormatException nfe) {
317        throw new IOException(
318          option + " must be specified either in the form 2001-02-20T16:35:06.99 "
319            + "or as number of milliseconds");
320      }
321    }
322    conf.setLong(option, ms);
323  }
324
325  /**
326   * Sets up the actual job.
327   * @param args The command line parameters.
328   * @return The newly created job.
329   * @throws IOException When setting up the job fails.
330   */
331  public Job createSubmittableJob(String[] args) throws IOException {
332    Configuration conf = getConf();
333    setupTime(conf, WALInputFormat.START_TIME_KEY);
334    setupTime(conf, WALInputFormat.END_TIME_KEY);
335    String inputDirs = args[0];
336    String[] tables = args.length == 1 ? new String[] {} : args[1].split(",");
337    String[] tableMap;
338    if (args.length > 2) {
339      tableMap = args[2].split(",");
340      if (tableMap.length != tables.length) {
341        throw new IOException("The same number of tables and mapping must be provided.");
342      }
343    } else {
344      // if no mapping is specified, map each table to itself
345      tableMap = tables;
346    }
347    conf.setStrings(TABLES_KEY, tables);
348    conf.setStrings(TABLE_MAP_KEY, tableMap);
349    conf.set(FileInputFormat.INPUT_DIR, inputDirs);
350    Job job = Job.getInstance(conf,
351      conf.get(JOB_NAME_CONF_KEY, NAME + "_" + EnvironmentEdgeManager.currentTime()));
352    job.setJarByClass(WALPlayer.class);
353
354    job.setInputFormatClass(WALInputFormat.class);
355    boolean diskBasedSortingEnabled = HFileOutputFormat2.diskBasedSortingEnabled(conf);
356    if (diskBasedSortingEnabled) {
357      job.setMapOutputKeyClass(KeyOnlyCellComparable.class);
358      job.setSortComparatorClass(KeyOnlyCellComparable.KeyOnlyCellComparator.class);
359    } else {
360      job.setMapOutputKeyClass(ImmutableBytesWritable.class);
361    }
362
363    String hfileOutPath = conf.get(BULK_OUTPUT_CONF_KEY);
364    if (hfileOutPath != null) {
365      LOG.debug("add incremental job :" + hfileOutPath + " from " + inputDirs);
366
367      // WALPlayer needs ExtendedCellSerialization so that sequenceId can be propagated when
368      // sorting cells in CellSortReducer
369      job.getConfiguration().setBoolean(HFileOutputFormat2.EXTENDED_CELL_SERIALIZATION_ENABLED_KEY,
370        true);
371
372      // the bulk HFile case
373      List<TableName> tableNames = getTableNameList(tables);
374
375      job.setMapperClass(WALKeyValueMapper.class);
376      if (diskBasedSortingEnabled) {
377        job.setReducerClass(PreSortedCellsReducer.class);
378      } else {
379        job.setReducerClass(CellSortReducer.class);
380      }
381      Path outputDir = new Path(hfileOutPath);
382      FileOutputFormat.setOutputPath(job, outputDir);
383      job.setMapOutputValueClass(MapReduceExtendedCell.class);
384      try (Connection conn = ConnectionFactory.createConnection(conf)) {
385        if (
386          conf.getBoolean(MULTI_TABLE_HFILEOUTPUTFORMAT_CONF_KEY,
387            MULTI_TABLE_HFILEOUTPUTFORMAT_CONF_DEFAULT)
388        ) {
389          // The HFiles will be output to something like this for each table:
390          // .../BULK_OUTPUT_CONF_KEY/namespace/table/columnFamily
391          List<TableInfo> tableInfoList = new ArrayList<TableInfo>();
392          for (TableName tableName : tableNames) {
393            Table table = conn.getTable(tableName);
394            RegionLocator regionLocator = getRegionLocator(tableName, conf, conn);
395            tableInfoList.add(new TableInfo(table.getDescriptor(), regionLocator));
396          }
397          MultiTableHFileOutputFormat.configureIncrementalLoad(job, tableInfoList);
398        } else {
399          // The HFiles will be output to something like: .../BULK_OUTPUT_CONF_KEY/columnFamily
400          // This is useful for scenarios where we are running the WALPlayer consecutively on just
401          // one table at a time, and BULK_OUTPUT_CONF_KEY is already set to a "namespace/table"
402          // directory path for each table.
403          if (tableNames.size() != 1) {
404            throw new IOException("Expected table names list to have only one table since "
405              + MULTI_TABLE_HFILEOUTPUTFORMAT_CONF_KEY + " is set to false. Got the following "
406              + "list of tables instead: " + tableNames);
407          }
408          TableName tableName = tableNames.get(0);
409          Table table = conn.getTable(tableName);
410          RegionLocator regionLocator = getRegionLocator(tableName, conf, conn);
411          HFileOutputFormat2.configureIncrementalLoad(job, table.getDescriptor(), regionLocator);
412        }
413      }
414      TableMapReduceUtil.addDependencyJarsForClasses(job.getConfiguration(),
415        org.apache.hbase.thirdparty.com.google.common.base.Preconditions.class);
416    } else {
417      // output to live cluster
418      job.setMapperClass(WALMapper.class);
419      job.setOutputFormatClass(MultiTableOutputFormat.class);
420      TableMapReduceUtil.addDependencyJars(job);
421      TableMapReduceUtil.initCredentials(job);
422      // No reducers.
423      job.setNumReduceTasks(0);
424    }
425    String codecCls = WALCellCodec.getWALCellCodecClass(conf).getName();
426    try {
427      TableMapReduceUtil.addDependencyJarsForClasses(job.getConfiguration(),
428        Class.forName(codecCls));
429    } catch (Exception e) {
430      throw new IOException("Cannot determine wal codec class " + codecCls, e);
431    }
432    return job;
433  }
434
435  private List<TableName> getTableNameList(String[] tables) {
436    List<TableName> list = new ArrayList<TableName>();
437    for (String name : tables) {
438      list.add(TableName.valueOf(name));
439    }
440    return list;
441  }
442
443  /**
444   * Print usage
445   * @param errorMsg Error message. Can be null.
446   */
447  private void usage(final String errorMsg) {
448    if (errorMsg != null && errorMsg.length() > 0) {
449      System.err.println("ERROR: " + errorMsg);
450    }
451    System.err.println("Usage: " + NAME + " [options] <WAL inputdir> [<tables> <tableMappings>]");
452    System.err.println(" <WAL inputdir>   directory of WALs to replay.");
453    System.err.println(" <tables>         comma separated list of tables. If no tables specified,");
454    System.err.println("                  all are imported (even hbase:meta if present).");
455    System.err.println(
456      " <tableMappings>  WAL entries can be mapped to a new set of tables by " + "passing");
457    System.err
458      .println("                  <tableMappings>, a comma separated list of target " + "tables.");
459    System.err
460      .println("                  If specified, each table in <tables> must have a " + "mapping.");
461    System.err.println("To generate HFiles to bulk load instead of loading HBase directly, pass:");
462    System.err.println(" -D" + BULK_OUTPUT_CONF_KEY + "=/path/for/output");
463    System.err.println(" Only one table can be specified, and no mapping allowed!");
464    System.err.println("To specify a time range, pass:");
465    System.err.println(" -D" + WALInputFormat.START_TIME_KEY + "=[date|ms]");
466    System.err.println(" -D" + WALInputFormat.END_TIME_KEY + "=[date|ms]");
467    System.err.println(" The start and the end date of timerange (inclusive). The dates can be");
468    System.err
469      .println(" expressed in milliseconds-since-epoch or yyyy-MM-dd'T'HH:mm:ss.SS " + "format.");
470    System.err.println(" E.g. 1234567890120 or 2009-02-13T23:32:30.12");
471    System.err.println("Other options:");
472    System.err.println(" -D" + JOB_NAME_CONF_KEY + "=jobName");
473    System.err.println(" Use the specified mapreduce job name for the wal player");
474    System.err.println(" -Dwal.input.separator=' '");
475    System.err.println(" Change WAL filename separator (WAL dir names use default ','.)");
476    System.err.println("For performance also consider the following options:\n"
477      + "  -Dmapreduce.map.speculative=false\n" + "  -Dmapreduce.reduce.speculative=false");
478  }
479
480  /**
481   * Main entry point.
482   * @param args The command line parameters.
483   * @throws Exception When running the job fails.
484   */
485  public static void main(String[] args) throws Exception {
486    int ret = ToolRunner.run(new WALPlayer(HBaseConfiguration.create()), args);
487    System.exit(ret);
488  }
489
490  @Override
491  public int run(String[] args) throws Exception {
492    if (args.length < 1) {
493      usage("Wrong number of arguments: " + args.length);
494      System.exit(-1);
495    }
496    Job job = createSubmittableJob(args);
497    return job.waitForCompletion(true) ? 0 : 1;
498  }
499
500  private static RegionLocator getRegionLocator(TableName tableName, Configuration conf,
501    Connection conn) throws IOException {
502    if (SnapshotRegionLocator.shouldUseSnapshotRegionLocator(conf, tableName)) {
503      return SnapshotRegionLocator.create(conf, tableName);
504    }
505
506    return conn.getRegionLocator(tableName);
507  }
508}