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

import java.io.IOException;
import java.io.PrintStream;
import java.io.InputStreamReader;
import java.io.BufferedReader;
import java.util.List;
import java.util.LinkedList;
import java.util.Map;
import java.util.HashMap;
import java.util.Set;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Date;
import java.text.SimpleDateFormat;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import org.apache.hadoop.fs.FileStatus;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.FSDataOutputStream;
import org.apache.hadoop.fs.Path;

import org.apache.hadoop.hdfs.DistributedFileSystem;
import org.apache.hadoop.hdfs.RaidDFSUtil;

import org.apache.hadoop.conf.Configuration;

import org.apache.hadoop.io.SequenceFile;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.io.WritableComparable;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Writable;

import org.apache.hadoop.util.StringUtils;

import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.InputFormat;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.JobContext;
import org.apache.hadoop.mapreduce.InputSplit;

import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.input.TextInputFormat;
import org.apache.hadoop.mapreduce.lib.input.SequenceFileInputFormat;
import org.apache.hadoop.mapreduce.lib.input.FileSplit;
import org.apache.hadoop.mapreduce.lib.output.TextOutputFormat;
import org.apache.hadoop.mapreduce.lib.output.SequenceFileOutputFormat;

/**
 * distributed block fixer, uses map reduce jobs to fix corrupt files
 *
 * configuration options
 * raid.blockfix.filespertask       - number of corrupt files to fix in a single
 *                                    map reduce task (i.e., at one mapper node)
 *
 * raid.blockfix.maxpendingfiles    - maximum number of files to fix 
 *                                    simultaneously
 */
public class DistBlockFixer extends BlockFixer {
  // volatile should be sufficient since only the block fixer thread
  // updates numJobsRunning (other threads may read)
  private volatile int numJobsRunning = 0;

  private static final String WORK_DIR_PREFIX = "blockfixer";
  private static final String IN_FILE_SUFFIX = ".in";
  private static final String PART_PREFIX = "part-";
  
  private static final String BLOCKFIX_FILES_PER_TASK = 
    "raid.blockfix.filespertask";
  private static final String BLOCKFIX_MAX_PENDING_FILES =
    "raid.blockfix.maxpendingfiles";

  // default number of files to fix in a task
  private static final long DEFAULT_BLOCKFIX_FILES_PER_TASK = 10L;

  // default number of files to fix simultaneously
  private static final long DEFAULT_BLOCKFIX_MAX_PENDING_FILES = 1000L;
 
  protected static final Log LOG = LogFactory.getLog(DistBlockFixer.class);

  // number of files to fix in a task
  private long filesPerTask;

  // number of files to fix simultaneously
  final private long maxPendingFiles;

  // number of files being fixed right now
  private long pendingFiles;

  private long lastCheckTime;

  private final SimpleDateFormat dateFormat = 
    new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

  private Map<String, CorruptFileInfo> fileIndex = 
    new HashMap<String, CorruptFileInfo>();
  private Map<Job, List<CorruptFileInfo>> jobIndex =
    new HashMap<Job, List<CorruptFileInfo>>();

  static enum Counter {
    FILES_SUCCEEDED, FILES_FAILED, FILES_NOACTION
  }

  public DistBlockFixer(Configuration conf) {
    super(conf);
    filesPerTask = DistBlockFixer.filesPerTask(getConf());
    maxPendingFiles = DistBlockFixer.maxPendingFiles(getConf());
    pendingFiles = 0L;

    // start off due for the first iteration
    lastCheckTime = System.currentTimeMillis() - blockFixInterval;
  }

  /**
   * determines how many files to fix in a single task
   */ 
  protected static long filesPerTask(Configuration conf) {
    return conf.getLong(BLOCKFIX_FILES_PER_TASK, 
                        DEFAULT_BLOCKFIX_FILES_PER_TASK);

  }
  /**
   * determines how many files to fix simultaneously
   */ 
  protected static long maxPendingFiles(Configuration conf) {
    return conf.getLong(BLOCKFIX_MAX_PENDING_FILES, 
                        DEFAULT_BLOCKFIX_MAX_PENDING_FILES);
  }

  /**
   * runs the block fixer periodically
   */
  public void run() {
    while (running) {
      // check if it is time to run the block fixer
      long now = System.currentTimeMillis();
      if (now >= lastCheckTime + blockFixInterval) {
        lastCheckTime = now;
        try {
          checkAndFixBlocks(now);
        } catch (InterruptedException ignore) {
          LOG.info("interrupted");
        } catch (Exception e) {
          // log exceptions and keep running
          LOG.error(StringUtils.stringifyException(e));
        } catch (Error e) {
          LOG.error(StringUtils.stringifyException(e));
          throw e;
        }
      }
      
      // try to sleep for the remainder of the interval
      long sleepPeriod = (lastCheckTime - System.currentTimeMillis()) + 
        blockFixInterval;
      
      if ((sleepPeriod > 0L) && running) {
        try {
          Thread.sleep(sleepPeriod);
        } catch (InterruptedException ignore) {
          LOG.info("interrupted");
        }
      }
    }
  }

  /**
   * checks for corrupt blocks and fixes them (if any)
   */
  private void checkAndFixBlocks(long startTime)
    throws IOException, InterruptedException, ClassNotFoundException {
    checkJobs();

    if (pendingFiles >= maxPendingFiles) {
      return;
    }

    List<Path> corruptFiles = getCorruptFiles();
    filterUnfixableSourceFiles(corruptFiles.iterator());

    String startTimeStr = dateFormat.format(new Date(startTime));

    LOG.info("found " + corruptFiles.size() + " corrupt files");

    if (corruptFiles.size() > 0) {
      String jobName = "blockfixer." + startTime;
      startJob(jobName, corruptFiles);
    }
  }

  /**
   * Handle a failed job.
   */
  private void failJob(Job job) throws IOException {
    // assume no files have been fixed
    LOG.error("DistBlockFixer job " + job.getJobID() + "(" + job.getJobName() +
      ") finished (failed)");
    for (CorruptFileInfo fileInfo: jobIndex.get(job)) {
      fileInfo.fail();
    }
    numJobsRunning--;
  }

  /**
   * Handle a successful job.
   */ 
  private void succeedJob(Job job, long filesSucceeded, long filesFailed)
    throws IOException {
    LOG.info("DistBlockFixer job " + job.getJobID() + "(" + job.getJobName() +
      ") finished (succeeded)");

    if (filesFailed == 0) {
      // no files have failed
      for (CorruptFileInfo fileInfo: jobIndex.get(job)) {
        fileInfo.succeed();
      }
    } else {
      // we have to look at the output to check which files have failed
      Set<String> failedFiles = getFailedFiles(job);
      
      for (CorruptFileInfo fileInfo: jobIndex.get(job)) {
        if (failedFiles.contains(fileInfo.getFile().toString())) {
          fileInfo.fail();
        } else {
          // call succeed for files that have succeeded or for which no action
          // was taken
          fileInfo.succeed();
        }
      }
    }
    // report succeeded files to metrics
    incrFilesFixed(filesSucceeded);
    numJobsRunning--;
  }

  /**
   * checks if jobs have completed and updates job and file index
   * returns a list of failed files for restarting
   */
  private void checkJobs() throws IOException {
    Iterator<Job> jobIter = jobIndex.keySet().iterator();
    while(jobIter.hasNext()) {
      Job job = jobIter.next();

      try {
        if (job.isComplete()) {
          long filesSucceeded =
            job.getCounters().findCounter(Counter.FILES_SUCCEEDED).getValue();
          long filesFailed =
            job.getCounters().findCounter(Counter.FILES_FAILED).getValue();
          long filesNoAction =
            job.getCounters().findCounter(Counter.FILES_NOACTION).getValue();
          int files = jobIndex.get(job).size();
          if (job.isSuccessful() && 
              (filesSucceeded + filesFailed + filesNoAction == 
               ((long) files))) {
            // job has processed all files
            succeedJob(job, filesSucceeded, filesFailed);
          } else {
            failJob(job);
          }
          jobIter.remove();
        } else {
          LOG.info("job " + job.getJobName() + " still running");
        }
      } catch (Exception e) {
        LOG.error(StringUtils.stringifyException(e));
        failJob(job);
        try {
          job.killJob();
        } catch (Exception ee) {
          LOG.error(StringUtils.stringifyException(ee));
        }
        jobIter.remove();
      }
    }
    purgeFileIndex();
  }

  /**
   * determines which files have failed for a given job
   */
  private Set<String> getFailedFiles(Job job) throws IOException {
    Set<String> failedFiles = new HashSet<String>();

    Path outDir = SequenceFileOutputFormat.getOutputPath(job);
    FileSystem fs  = outDir.getFileSystem(getConf());
    if (!fs.getFileStatus(outDir).isDir()) {
      throw new IOException(outDir.toString() + " is not a directory");
    }

    FileStatus[] files = fs.listStatus(outDir);

    for (FileStatus f: files) {
      Path fPath = f.getPath();
      if ((!f.isDir()) && (fPath.getName().startsWith(PART_PREFIX))) {
        LOG.info("opening " + fPath.toString());
        SequenceFile.Reader reader = 
          new SequenceFile.Reader(fs, fPath, getConf());

        Text key = new Text();
        Text value = new Text();
        while (reader.next(key, value)) {
          failedFiles.add(key.toString());
        }
        reader.close();
      }
    }
    return failedFiles;
  }


  /**
   * purge expired jobs from the file index
   */
  private void purgeFileIndex() {
    Iterator<String> fileIter = fileIndex.keySet().iterator();
    while(fileIter.hasNext()) {
      String file = fileIter.next();
      if (fileIndex.get(file).isExpired()) {
        fileIter.remove();
      }
    }
    
  }

  /**
   * creates and submits a job, updates file index and job index
   */
  private Job startJob(String jobName, List<Path> corruptFiles) 
    throws IOException, InterruptedException, ClassNotFoundException {
    Path inDir = new Path(WORK_DIR_PREFIX + "/in/" + jobName);
    Path outDir = new Path(WORK_DIR_PREFIX + "/out/" + jobName);
    List<Path> filesInJob = createInputFile(jobName, inDir, corruptFiles);

    Configuration jobConf = new Configuration(getConf());
    Job job = new Job(jobConf, jobName);
    job.setJarByClass(getClass());
    job.setMapperClass(DistBlockFixerMapper.class);
    job.setNumReduceTasks(0);
    job.setInputFormatClass(DistBlockFixerInputFormat.class);
    job.setOutputFormatClass(SequenceFileOutputFormat.class);
    job.setOutputKeyClass(Text.class);
    job.setOutputValueClass(Text.class);

    DistBlockFixerInputFormat.setInputPaths(job, inDir);
    SequenceFileOutputFormat.setOutputPath(job, outDir);

    job.submit();
    LOG.info("DistBlockFixer job " + job.getJobID() + "(" + job.getJobName() +
          ") started");

    // submit the job before inserting it into the index
    // this way, if submit fails, we won't have added anything to the index
    insertJob(job, filesInJob);
    return job;
  }

  /**
   * inserts new job into file index and job index
   */
  private void insertJob(Job job, List<Path> corruptFiles) {
    List<CorruptFileInfo> fileInfos = new LinkedList<CorruptFileInfo>();

    for (Path file: corruptFiles) {
      CorruptFileInfo fileInfo = new CorruptFileInfo(file, job);
      fileInfos.add(fileInfo);
      fileIndex.put(file.toString(), fileInfo);
    }

    jobIndex.put(job, fileInfos);
    numJobsRunning++;
  }
    
  /**
   * creates the input file (containing the names of the files to be fixed
   */
  private List<Path> createInputFile(String jobName, Path inDir, 
                                     List<Path> corruptFiles) 
    throws IOException {

    Path file = new Path(inDir, jobName + IN_FILE_SUFFIX);
    FileSystem fs = file.getFileSystem(getConf());
    SequenceFile.Writer fileOut = SequenceFile.createWriter(fs, getConf(), file,
                                                            LongWritable.class,
                                                            Text.class);
    long index = 0L;

    List<Path> filesAdded = new LinkedList<Path>();

    for (Path corruptFile: corruptFiles) {
      if (pendingFiles >= maxPendingFiles) {
        break;
      }

      String corruptFileName = corruptFile.toString();
      fileOut.append(new LongWritable(index++), new Text(corruptFileName));
      filesAdded.add(corruptFile);
      pendingFiles++;

      if (index % filesPerTask == 0) {
        fileOut.sync(); // create sync point to make sure we can split here
      }
    }

    fileOut.close();
    return filesAdded;
  }

  /**
   * gets a list of corrupt files from the name node
   * and filters out files that are currently being fixed or 
   * that were recently fixed
   */
  private List<Path> getCorruptFiles() throws IOException {
    DistributedFileSystem dfs = (DistributedFileSystem) 
      (new Path("/")).getFileSystem(getConf());

    String[] files = RaidDFSUtil.getCorruptFiles(dfs);
    List<Path> corruptFiles = new LinkedList<Path>();

    for (String f: files) {
      Path p = new Path(f);
      // filter out files that are being fixed or that were recently fixed
      if (!fileIndex.containsKey(p.toString())) {
        corruptFiles.add(p);
      }
    }
    RaidUtils.filterTrash(getConf(), corruptFiles);

    return corruptFiles;
  }

  /**
   * returns the number of map reduce jobs running
   */
  public int jobsRunning() {
    return numJobsRunning;
  }

  /**
   * hold information about a corrupt file that is being fixed
   */
  class CorruptFileInfo {

    private Path file;
    private Job job;
    private boolean done;
    private long time;

    public CorruptFileInfo(Path file, Job job) {
      this.file = file;
      this.job = job;
      this.done = false;
      this.time = 0;
    }

    public boolean isDone() {
      return done;
    }
    
    public boolean isExpired() {
      return done && ((System.currentTimeMillis() - time) > historyInterval);
    }

    public Path getFile() {
      return file;
    }
    
    /**
     * updates file index to record a failed attempt at fixing a file,
     * immediately removes the entry from the file index 
     * (instead of letting it expire)
     * so that we can retry right away
     */
    public void fail() {
      // remove this file from the index
      CorruptFileInfo removed = fileIndex.remove(file.toString());
      if (removed == null) {
        LOG.error("trying to remove file not in file index: " +
                  file.toString());
      } else {
        LOG.error("fixing " + file.toString() + " failed");
      }
      pendingFiles--;
    }

    /**
     * marks a file as fixed successfully
     * and sets time stamp for expiry after specified interval
     */
    public void succeed() {
      // leave the file in the index,
      // will be pruged later
      job = null;
      done = true;
      time = System.currentTimeMillis();
      LOG.info("fixing " + file.toString() + " succeeded");
      pendingFiles--;
    }
  }

  static class DistBlockFixerInputFormat
    extends SequenceFileInputFormat<LongWritable, Text> {

    protected static final Log LOG = 
      LogFactory.getLog(DistBlockFixerMapper.class);
    
    /**
     * splits the input files into tasks handled by a single node
     * we have to read the input files to do this based on a number of 
     * items in a sequence
     */
    @Override
    public List <InputSplit> getSplits(JobContext job) 
      throws IOException {
      long filesPerTask = DistBlockFixer.filesPerTask(job.getConfiguration());

      Path[] inPaths = getInputPaths(job);

      List<InputSplit> splits = new LinkedList<InputSplit>();

      long fileCounter = 0;

      for (Path inPath: inPaths) {
        
        FileSystem fs = inPath.getFileSystem(job.getConfiguration());      

        if (!fs.getFileStatus(inPath).isDir()) {
          throw new IOException(inPath.toString() + " is not a directory");
        }

        FileStatus[] inFiles = fs.listStatus(inPath);

        for (FileStatus inFileStatus: inFiles) {
          Path inFile = inFileStatus.getPath();
          
          if (!inFileStatus.isDir() &&
              (inFile.getName().equals(job.getJobName() + IN_FILE_SUFFIX))) {

            fileCounter++;
            SequenceFile.Reader inFileReader = 
              new SequenceFile.Reader(fs, inFile, job.getConfiguration());
            
            long startPos = inFileReader.getPosition();
            long counter = 0;
            
            // create an input split every filesPerTask items in the sequence
            LongWritable key = new LongWritable();
            Text value = new Text();
            try {
              while (inFileReader.next(key, value)) {
                if (counter % filesPerTask == filesPerTask - 1L) {
                  splits.add(new FileSplit(inFile, startPos, 
                                           inFileReader.getPosition() - 
                                           startPos,
                                           null));
                  startPos = inFileReader.getPosition();
                }
                counter++;
              }
              
              // create input split for remaining items if necessary
              // this includes the case where no splits were created by the loop
              if (startPos != inFileReader.getPosition()) {
                splits.add(new FileSplit(inFile, startPos,
                                         inFileReader.getPosition() - startPos,
                                         null));
              }
            } finally {
              inFileReader.close();
            }
          }
        }
      }

      LOG.info("created " + splits.size() + " input splits from " +
               fileCounter + " files");
      
      return splits;
    }

    /**
     * indicates that input file can be split
     */
    @Override
    public boolean isSplitable (JobContext job, Path file) {
      return true;
    }
  }


  /**
   * mapper for fixing stripes with corrupt blocks
   */
  static class DistBlockFixerMapper
    extends Mapper<LongWritable, Text, Text, Text> {

    protected static final Log LOG = 
      LogFactory.getLog(DistBlockFixerMapper.class);

    /**
     * fix a stripe
     */
    @Override
    public void map(LongWritable key, Text fileText, Context context) 
      throws IOException, InterruptedException {
      
      BlockFixerHelper helper = 
        new BlockFixerHelper(context.getConfiguration());

      String fileStr = fileText.toString();
      LOG.info("fixing " + fileStr);

      Path file = new Path(fileStr);
      boolean success = false;

      try {
        boolean fixed = helper.fixFile(file, context);
        
        if (fixed) {
          context.getCounter(Counter.FILES_SUCCEEDED).increment(1L);
        } else {
          context.getCounter(Counter.FILES_NOACTION).increment(1L);
        }
      } catch (Exception e) {
        LOG.error(StringUtils.stringifyException(e));

        // report file as failed
        context.getCounter(Counter.FILES_FAILED).increment(1L);
        String outkey = fileStr;
        String outval = "failed";
        context.write(new Text(outkey), new Text(outval));
      }
      
      context.progress();
    }
  }

}
