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

import static java.lang.String.format;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import com.codahale.metrics.Histogram;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Queue;
import java.util.Set;
import java.util.concurrent.Callable;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.hbase.chaos.actions.MoveRandomRegionOfTableAction;
import org.apache.hadoop.hbase.chaos.actions.RestartRandomRsExceptMetaAction;
import org.apache.hadoop.hbase.chaos.monkies.PolicyBasedChaosMonkey;
import org.apache.hadoop.hbase.chaos.policies.PeriodicRandomActionPolicy;
import org.apache.hadoop.hbase.chaos.policies.Policy;
import org.apache.hadoop.hbase.client.Admin;
import org.apache.hadoop.hbase.ipc.RpcClient;
import org.apache.hadoop.hbase.regionserver.DisabledRegionSplitPolicy;
import org.apache.hadoop.hbase.testclassification.IntegrationTests;
import org.apache.hadoop.hbase.util.YammerHistogramUtils;
import org.apache.hadoop.mapreduce.Counters;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.util.ToolRunner;
import org.junit.jupiter.api.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.apache.hbase.thirdparty.com.google.common.base.MoreObjects;
import org.apache.hbase.thirdparty.com.google.common.collect.Sets;
import org.apache.hbase.thirdparty.org.apache.commons.cli.CommandLine;

/**
 * Test for comparing the performance impact of region replicas. Uses components of
 * {@link PerformanceEvaluation}. Does not run from {@code IntegrationTestsDriver} because
 * IntegrationTestBase is incompatible with the JUnit runner. Hence no @Test annotations either. See
 * {@code -help} for full list of options.
 */
@Tag(IntegrationTests.TAG)
public class IntegrationTestRegionReplicaPerf extends IntegrationTestBase {

  private static final Logger LOG = LoggerFactory.getLogger(IntegrationTestRegionReplicaPerf.class);

  private static final String SLEEP_TIME_KEY = "sleeptime";
  // short default interval because tests don't run very long.
  private static final String SLEEP_TIME_DEFAULT = "" + (10 * 1000l);
  private static final String TABLE_NAME_KEY = "tableName";
  private static final String TABLE_NAME_DEFAULT = "IntegrationTestRegionReplicaPerf";
  private static final String REPLICA_COUNT_KEY = "replicas";
  private static final String REPLICA_COUNT_DEFAULT = "" + 3;
  private static final String PRIMARY_TIMEOUT_KEY = "timeout";
  private static final String PRIMARY_TIMEOUT_DEFAULT = "" + 10 * 1000; // 10 ms
  private static final String NUM_RS_KEY = "numRs";
  private static final String NUM_RS_DEFAULT = "" + 3;
  public static final String FAMILY_NAME = "info";

  /** Extract a descriptive statistic from a {@link com.codahale.metrics.Histogram}. */
  private enum Stat {
    STDEV {
      @Override
      double apply(Histogram hist) {
        return hist.getSnapshot().getStdDev();
      }
    },
    FOUR_9S {
      @Override
      double apply(Histogram hist) {
        return hist.getSnapshot().getValue(0.9999);
      }
    };

    abstract double apply(Histogram hist);
  }

  private TableName tableName;
  private long sleepTime;
  private int replicaCount;
  private int primaryTimeout;
  private int clusterSize;

  /**
   * Wraps the invocation of {@link PerformanceEvaluation} in a {@code Callable}.
   */
  static class PerfEvalCallable implements Callable<TimingResult> {
    private final Queue<String> argv = new ArrayDeque<>();
    private final Admin admin;

    public PerfEvalCallable(Admin admin, String argv) {
      // TODO: this API is awkward, should take Connection, not Admin
      this.admin = admin;
      this.argv.addAll(Arrays.asList(argv.split(" ")));
      LOG.debug("Created PerformanceEvaluationCallable with args: " + argv);
    }

    @Override
    public TimingResult call() throws Exception {
      PerformanceEvaluation.TestOptions opts = PerformanceEvaluation.parseOpts(argv);
      PerformanceEvaluation.checkTable(admin, opts);
      PerformanceEvaluation.RunResult results[] = null;
      long numRows = opts.totalRows;
      long elapsedTime = 0;
      if (opts.nomapred) {
        results = PerformanceEvaluation.doLocalClients(opts, admin.getConfiguration());
        for (PerformanceEvaluation.RunResult r : results) {
          elapsedTime = Math.max(elapsedTime, r.duration);
        }
      } else {
        Job job = PerformanceEvaluation.doMapReduce(opts, admin.getConfiguration());
        Counters counters = job.getCounters();
        numRows = counters.findCounter(PerformanceEvaluation.Counter.ROWS).getValue();
        elapsedTime = counters.findCounter(PerformanceEvaluation.Counter.ELAPSED_TIME).getValue();
      }
      return new TimingResult(numRows, elapsedTime, results);
    }
  }

  /**
   * Record the results from a single {@link PerformanceEvaluation} job run.
   */
  static class TimingResult {
    public final long numRows;
    public final long elapsedTime;
    public final PerformanceEvaluation.RunResult results[];

    public TimingResult(long numRows, long elapsedTime, PerformanceEvaluation.RunResult results[]) {
      this.numRows = numRows;
      this.elapsedTime = elapsedTime;
      this.results = results;
    }

    @Override
    public String toString() {
      return MoreObjects.toStringHelper(this).add("numRows", numRows)
        .add("elapsedTime", elapsedTime).toString();
    }
  }

  @Override
  public void setUp() throws Exception {
    super.setUp();
    Configuration conf = util.getConfiguration();

    // sanity check cluster
    // TODO: this should reach out to master and verify online state instead
    assertEquals("org.apache.hadoop.hbase.master.balancer.StochasticLoadBalancer",
      conf.get("hbase.master.loadbalancer.class"),
      "Master must be configured with StochasticLoadBalancer");
    // TODO: this should reach out to master and verify online state instead
    assertTrue(conf.getLong("hbase.regionserver.storefile.refresh.period", 0) > 0,
      "hbase.regionserver.storefile.refresh.period must be greater than zero.");

    // enable client-side settings
    conf.setBoolean(RpcClient.SPECIFIC_WRITE_THREAD, true);
    // TODO: expose these settings to CLI override
    conf.setLong("hbase.client.primaryCallTimeout.get", primaryTimeout);
    conf.setLong("hbase.client.primaryCallTimeout.multiget", primaryTimeout);
  }

  @Override
  public void setUpCluster() throws Exception {
    util = getTestingUtil(getConf());
    util.initializeCluster(clusterSize);
  }

  @Override
  public void setUpMonkey() throws Exception {
    Policy p = new PeriodicRandomActionPolicy(sleepTime,
      new RestartRandomRsExceptMetaAction(sleepTime), new MoveRandomRegionOfTableAction(tableName));
    this.monkey = new PolicyBasedChaosMonkey(util, p);
    // don't start monkey right away
  }

  @Override
  protected void addOptions() {
    addOptWithArg(TABLE_NAME_KEY, "Alternate table name. Default: '" + TABLE_NAME_DEFAULT + "'");
    addOptWithArg(SLEEP_TIME_KEY,
      "How long the monkey sleeps between actions. Default: " + SLEEP_TIME_DEFAULT);
    addOptWithArg(REPLICA_COUNT_KEY,
      "Number of region replicas. Default: " + REPLICA_COUNT_DEFAULT);
    addOptWithArg(PRIMARY_TIMEOUT_KEY,
      "Overrides hbase.client.primaryCallTimeout. Default: " + PRIMARY_TIMEOUT_DEFAULT + " (10ms)");
    addOptWithArg(NUM_RS_KEY,
      "Specify the number of RegionServers to use. Default: " + NUM_RS_DEFAULT);
  }

  @Override
  protected void processOptions(CommandLine cmd) {
    tableName = TableName.valueOf(cmd.getOptionValue(TABLE_NAME_KEY, TABLE_NAME_DEFAULT));
    sleepTime = Long.parseLong(cmd.getOptionValue(SLEEP_TIME_KEY, SLEEP_TIME_DEFAULT));
    replicaCount = Integer.parseInt(cmd.getOptionValue(REPLICA_COUNT_KEY, REPLICA_COUNT_DEFAULT));
    primaryTimeout =
      Integer.parseInt(cmd.getOptionValue(PRIMARY_TIMEOUT_KEY, PRIMARY_TIMEOUT_DEFAULT));
    clusterSize = Integer.parseInt(cmd.getOptionValue(NUM_RS_KEY, NUM_RS_DEFAULT));
    LOG.debug(MoreObjects.toStringHelper("Parsed Options").add(TABLE_NAME_KEY, tableName)
      .add(SLEEP_TIME_KEY, sleepTime).add(REPLICA_COUNT_KEY, replicaCount)
      .add(PRIMARY_TIMEOUT_KEY, primaryTimeout).add(NUM_RS_KEY, clusterSize).toString());
  }

  @Override
  public int runTestFromCommandLine() throws Exception {
    test();
    return 0;
  }

  @Override
  public TableName getTablename() {
    return tableName;
  }

  @Override
  protected Set<String> getColumnFamilies() {
    return Sets.newHashSet(FAMILY_NAME);
  }

  /** Compute the mean of the given {@code stat} from a timing results. */
  private static double calcMean(String desc, Stat stat, List<TimingResult> results) {
    double sum = 0;
    int count = 0;

    for (TimingResult tr : results) {
      for (PerformanceEvaluation.RunResult r : tr.results) {
        assertNotNull(r.hist, "One of the run results is missing detailed run data.");
        sum += stat.apply(r.hist);
        count += 1;
        LOG.debug(desc + "{" + YammerHistogramUtils.getHistogramReport(r.hist) + "}");
      }
    }
    return sum / count;
  }

  public void test() throws Exception {
    int maxIters = 3;
    String replicas = "--replicas=" + replicaCount;
    // TODO: splits disabled until "phase 2" is complete.
    String splitPolicy = "--splitPolicy=" + DisabledRegionSplitPolicy.class.getName();
    String writeOpts =
      format("%s --nomapred --table=%s --presplit=16 sequentialWrite 4", splitPolicy, tableName);
    String readOpts =
      format("--nomapred --table=%s --latency --sampleRate=0.1 randomRead 4", tableName);
    String replicaReadOpts = format("%s %s", replicas, readOpts);

    ArrayList<TimingResult> resultsWithoutReplicas = new ArrayList<>(maxIters);
    ArrayList<TimingResult> resultsWithReplicas = new ArrayList<>(maxIters);

    // create/populate the table, replicas disabled
    LOG.debug("Populating table.");
    new PerfEvalCallable(util.getAdmin(), writeOpts).call();

    // one last sanity check, then send in the clowns!
    assertEquals(DisabledRegionSplitPolicy.class.getName(),
      util.getAdmin().getTableDescriptor(tableName).getRegionSplitPolicyClassName(),
      "Table must be created with DisabledRegionSplitPolicy. Broken test.");
    startMonkey();

    // collect a baseline without region replicas.
    for (int i = 0; i < maxIters; i++) {
      LOG.debug("Launching non-replica job " + (i + 1) + "/" + maxIters);
      resultsWithoutReplicas.add(new PerfEvalCallable(util.getAdmin(), readOpts).call());
      // TODO: sleep to let cluster stabilize, though monkey continues. is it necessary?
      Thread.sleep(5000l);
    }

    // disable monkey, enable region replicas, enable monkey
    cleanUpMonkey("Altering table.");
    LOG.debug("Altering " + tableName + " replica count to " + replicaCount);
    IntegrationTestingUtility.setReplicas(util.getAdmin(), tableName, replicaCount);
    setUpMonkey();
    startMonkey();

    // run test with region replicas.
    for (int i = 0; i < maxIters; i++) {
      LOG.debug("Launching replica job " + (i + 1) + "/" + maxIters);
      resultsWithReplicas.add(new PerfEvalCallable(util.getAdmin(), replicaReadOpts).call());
      // TODO: sleep to let cluster stabilize, though monkey continues. is it necessary?
      Thread.sleep(5000l);
    }

    // compare the average of the stdev and 99.99pct across runs to determine if region replicas
    // are having an overall improvement on response variance experienced by clients.
    double withoutReplicasStdevMean =
      calcMean("withoutReplicas", Stat.STDEV, resultsWithoutReplicas);
    double withoutReplicas9999Mean =
      calcMean("withoutReplicas", Stat.FOUR_9S, resultsWithoutReplicas);
    double withReplicasStdevMean = calcMean("withReplicas", Stat.STDEV, resultsWithReplicas);
    double withReplicas9999Mean = calcMean("withReplicas", Stat.FOUR_9S, resultsWithReplicas);

    LOG.info(MoreObjects.toStringHelper(this).add("withoutReplicas", resultsWithoutReplicas)
      .add("withReplicas", resultsWithReplicas)
      .add("withoutReplicasStdevMean", withoutReplicasStdevMean)
      .add("withoutReplicas99.99Mean", withoutReplicas9999Mean)
      .add("withReplicasStdevMean", withReplicasStdevMean)
      .add("withReplicas99.99Mean", withReplicas9999Mean).toString());

    assertTrue(withReplicasStdevMean <= withoutReplicasStdevMean,
      "Running with region replicas under chaos should have less request variance than without. "
        + "withReplicas.stdev.mean: " + withReplicasStdevMean + "ms "
        + "withoutReplicas.stdev.mean: " + withoutReplicasStdevMean + "ms.");
    assertTrue(withReplicas9999Mean <= withoutReplicas9999Mean,
      "Running with region replicas under chaos should improve 99.99pct latency. "
        + "withReplicas.99.99.mean: " + withReplicas9999Mean + "ms "
        + "withoutReplicas.99.99.mean: " + withoutReplicas9999Mean + "ms.");
  }

  public static void main(String[] args) throws Exception {
    Configuration conf = HBaseConfiguration.create();
    IntegrationTestingUtility.setUseDistributedCluster(conf);
    int status = ToolRunner.run(conf, new IntegrationTestRegionReplicaPerf(), args);
    System.exit(status);
  }
}
