Sampler.java

/*
 * Copyright (c) 2001-2017, Zoltan Farkas All Rights Reserved.
 *
 * This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 2.1 of the License, or (at your option) any later version.
 *
 * This library is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this program; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
 *
 * Additionally licensed with:
 *
 * Licensed 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.spf4j.stackmonitor;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.management.ManagementFactory;
import java.time.Instant;
import java.util.Collections;
import java.util.Date;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.PreDestroy;
import javax.annotation.concurrent.GuardedBy;
import javax.annotation.concurrent.ThreadSafe;
import org.spf4j.base.AbstractRunnable;
import org.spf4j.base.CharSequences;
import org.spf4j.base.SuppressForbiden;
import org.spf4j.base.TimeSource;
import org.spf4j.base.Timing;
import org.spf4j.concurrent.DefaultExecutor;
import org.spf4j.jmx.JmxExport;
import org.spf4j.jmx.Registry;

/**
 * Utility to sample stack traces. Stack traces can be persisted for later analysis.
 *
 * please read http://sape.inf.usi.ch/sites/default/files/publication/pldi10.pdf pure java stack sampling will probably
 * have safepoint bias.
 *
 * @author zoly
 */
@ThreadSafe
public final class Sampler {

  private static Sampler instance;

  public static final String DEFAULT_SS_DUMP_FOLDER = System.getProperty("spf4j.perf.ms.defaultSsdumpFolder",
          System.getProperty("java.io.tmpdir"));

  public static final String DEFAULT_SS_DUMP_FILE_NAME_PREFIX = CharSequences.validatedFileName(
          System.getProperty("spf4j.perf.ms.defaultSsdumpFilePrefix",
                  ManagementFactory.getRuntimeMXBean().getName()));

  @GuardedBy("sync")
  private boolean stopped;

  private volatile long sampleTimeNanos;
  private volatile long dumpTimeNanos;
  private final SamplerSupplier stackCollectorSupp;
  private volatile long lastDumpTimeNanos;

  private final Object sync = new Object();

  @GuardedBy("sync")
  private ISampler stackCollector;

  @GuardedBy("sync")
  private Future<?> samplerFuture;

  private volatile ProfilePersister persister;

  @Override
  public String toString() {
    return "Sampler{" + "stopped=" + stopped + ", sampleTimeNanos="
            + sampleTimeNanos + ", dumpTimeNanos=" + dumpTimeNanos + ", lastDumpTimeNanos="
            + lastDumpTimeNanos + ", dumpFolder=" + getDumpFolder() + ", filePrefix=" + getFilePrefix() + '}';
  }

  public Sampler() {
    this(10, 3600000, (t) -> new FastStackCollector(false, true, new Thread[]{t}));
  }

  public Sampler(final int sampleTimeMillis) {
    this(sampleTimeMillis, 3600000, (t) -> new FastStackCollector(false, true, new Thread[]{t}));
  }

  public Sampler(final int sampleTimeMillis, final SamplerSupplier collector) {
    this(sampleTimeMillis, 3600000, collector);
  }

  public Sampler(final SamplerSupplier collector) {
    this(10, 3600000, collector);
  }

  @SuppressFBWarnings("PATH_TRAVERSAL_IN")
  public Sampler(final int sampleTimeMillis, final int dumpTimeMillis) {
    this(sampleTimeMillis, dumpTimeMillis, (t) -> new FastStackCollector(false, true, new Thread[]{t}),
            DEFAULT_SS_DUMP_FOLDER, DEFAULT_SS_DUMP_FILE_NAME_PREFIX);
  }

  @SuppressFBWarnings("PATH_TRAVERSAL_IN")
  public Sampler(final int sampleTimeMillis, final int dumpTimeMillis, final SamplerSupplier collector) {
    this(sampleTimeMillis, dumpTimeMillis, collector,
            DEFAULT_SS_DUMP_FOLDER, DEFAULT_SS_DUMP_FILE_NAME_PREFIX);
  }

  @SuppressFBWarnings("PATH_TRAVERSAL_IN")
  public Sampler(final int sampleTimeMillis, final int dumpTimeMillis, final SamplerSupplier collector,
          final String dumpFolder, final String dumpFilePrefix) {
    this(sampleTimeMillis, dumpTimeMillis, collector, new File(dumpFolder), dumpFilePrefix);
  }

  public Sampler(final int sampleTimeMillis, final int dumpTimeMillis, final SamplerSupplier collector,
          final File dumpFolder, final String dumpFilePrefix) {
    this(sampleTimeMillis, dumpTimeMillis, collector, dumpFolder, dumpFilePrefix, true);
  }

  public Sampler(final int sampleTimeMillis, final int dumpTimeMillis, final SamplerSupplier collector,
          final File dumpFolder, final String dumpFilePrefix, final boolean compressDumps) {
    this(sampleTimeMillis, dumpTimeMillis, collector,
            new LegacyProfilePersister(dumpFolder.toPath(), dumpFilePrefix, compressDumps));
  }

  public Sampler(final int sampleTimeMillis, final int dumpTimeMillis, final SamplerSupplier collector,
          final ProfilePersister persister) {
    stopped = true;
    if (sampleTimeMillis < 1) {
      throw new IllegalArgumentException("Invalid sample time " + sampleTimeMillis);
    }
    this.sampleTimeNanos = TimeUnit.MILLISECONDS.toNanos(sampleTimeMillis);
    if (sampleTimeNanos < 0) {
      throw new IllegalArgumentException("Invalid sample time " + sampleTimeMillis);
    }
    this.dumpTimeNanos = TimeUnit.MILLISECONDS.toNanos(dumpTimeMillis);
    this.stackCollectorSupp = collector;
    this.persister = persister;
  }


  public static synchronized Sampler getSampler(final int sampleTimeMillis,
          final int dumpTimeMillis,
          final File dumpFolder, final String dumpFilePrefix) throws InterruptedException, IOException {
    return getSampler(sampleTimeMillis, dumpTimeMillis,
            (t) -> new FastStackCollector(false, true, new Thread[]{t}), dumpFolder, dumpFilePrefix);
  }

  /**
   * Gets a sampler.
   * This method will always creates a new sampler instance and will dispose of any sampler
   * instances previously created with this method.
   * There can only be one sampler instance running at a time.
   * @param sampleTimeMillis
   * @param dumpTimeMillis
   * @param collector
   * @param dumpFolder
   * @param dumpFilePrefix
   * @return
   * @throws InterruptedException
   */
  @SuppressFBWarnings("MS_EXPOSE_REP")
  public static synchronized Sampler getSampler(final int sampleTimeMillis,
          final int dumpTimeMillis, final SamplerSupplier collector,
          final File dumpFolder, final String dumpFilePrefix) throws InterruptedException, IOException {
    if (instance != null) {
      instance.dispose();
    }
    try {
      instance = new Sampler(sampleTimeMillis, dumpTimeMillis, collector,
              dumpFolder.getCanonicalFile(), dumpFilePrefix);
    } catch (IOException ex) {
      throw new UncheckedIOException(ex);
    }
    instance.registerJmx();
    return instance;
  }

  public void registerJmx() {
    Registry.export(this);
  }

  @JmxExport(description = "start stack sampling")
  public void start() {
    synchronized (sync) {
      if (stopped) {
        stopped = false;
        final long stNanos = sampleTimeNanos;
        samplerFuture = DefaultExecutor.INSTANCE.submit(new AbstractRunnable("SPF4J-Sampling-Thread") {

          @SuppressWarnings("SleepWhileInLoop")
          @SuppressFBWarnings({"MDM_THREAD_YIELD", "PREDICTABLE_RANDOM"})
          @Override
          public void doRun() {
            lastDumpTimeNanos = TimeSource.nanoTime();
            synchronized (sync) {
              stackCollector = stackCollectorSupp.get(Thread.currentThread());
            }
            final long lDumpTimeNanos = dumpTimeNanos;
            final ThreadLocalRandom random = ThreadLocalRandom.current();
            long dumpCounterNanos = 0;
            long sleepTimeNanos = 0;
            long halfStNanos = stNanos / 2;
            if (halfStNanos == 0) {
              halfStNanos = 1;
            }
            long maxSleeepNanos = stNanos + halfStNanos;
            while (true) {
              try {
                synchronized (sync) {
                  stackCollector.sample();
                  if (stopped) {
                    break;
                  }
                }
                dumpCounterNanos += sleepTimeNanos;
                if (dumpCounterNanos >= lDumpTimeNanos) {
                  long nanosSinceLastDump = TimeSource.nanoTime() - lastDumpTimeNanos;
                  if (nanosSinceLastDump >= lDumpTimeNanos) {
                    dumpCounterNanos = 0;
                    File dumpFile = dumpToFile();
                    if (dumpFile != null) {
                      Logger.getLogger(Sampler.class.getName())
                              .log(Level.INFO, "Stack samples written to {0}", dumpFile);
                    }
                  } else {
                    dumpCounterNanos = nanosSinceLastDump;
                  }
                }
                sleepTimeNanos = random.nextLong(halfStNanos, maxSleeepNanos);
                TimeUnit.NANOSECONDS.sleep(sleepTimeNanos);
              } catch (InterruptedException ex) {
                Thread.currentThread().interrupt();
                return;
              } catch (IOException | RuntimeException ex) {
                Logger.getLogger(Sampler.class.getName()).log(Level.SEVERE,
                        "Exception encountered while samplig, will continue sampling", ex);
              }
            }
          }
        });
      } else {
        throw new IllegalStateException("Sampling can only be started once for " + this);
      }
    }
  }

  @JmxExport
  public boolean isCompressDumps() {
    return persister.isCompressing();
  }

  @JmxExport
  public void setCompressDumps(final boolean compressDumps) throws IOException {
    ProfilePersister p = this.persister;
    p.close();
    this.persister = p.witCompression(compressDumps);
  }


  private static class ProfileData {
    private final Instant from;
    private final Instant to;
    private final Map<String, SampleNode> samples;

    ProfileData(final Instant from, final Instant to, final Map<String, SampleNode> samples) {
      this.from = from;
      this.to = to;
      this.samples = samples;
    }

    public Instant getFrom() {
      return from;
    }

    public Instant getTo() {
      return to;
    }

    public Map<String, SampleNode> getSamples() {
      return samples;
    }
  }

  @Nullable
  private ProfileData getAndResetProfileSamples() {
    long fromNanos;
    Map<String, SampleNode> collections;
    long nowNanos;
    synchronized (sync) {
      if (stackCollector == null) {
        return null;
      }
      collections = stackCollector.getCollectionsAndReset();
      if (collections.isEmpty()) {
        return null;
      }
      fromNanos = lastDumpTimeNanos;
      nowNanos = TimeSource.nanoTime();
      lastDumpTimeNanos = nowNanos;
    }
    Timing currentTiming = Timing.getCurrentTiming();
    return new ProfileData(currentTiming.fromNanoTimeToInstant(fromNanos),
            currentTiming.fromNanoTimeToInstant(nowNanos), collections);

  }

  @JmxExport(description = "save stack samples to file")
  @Nullable
  public File dumpToFile() throws IOException {
    ProfileData data = getAndResetProfileSamples();
    if (data == null) {
      return null;
    }
    return persister.persist(data.getSamples(), null, data.getFrom(), data.getTo()).toFile();
  }

  /**
   * Dumps the sampled stacks to file. the collected samples are reset
   *
   * @param id - id will be added to file name returns the name of the file.
   * @return - the file name where the data was persisted or null if there was no data to persist. The file name
   * will be of format: [filePrefix](_[id])?_["yyyyMMdd'T'HHmmssSSSz]_["yyyyMMdd'T'HHmmssSSSz]_[label].ssdump2(.gz)?
   * or [filePrefix](_[id])?_["yyyyMMdd'T'HHmmssSSSz]_["yyyyMMdd'T'HHmmssSSSz].ssdump3(.gz)?
   * @throws IOException - io issues while persisting data.
   */
  @JmxExport(value = "dumpToSpecificFile", description = "save stack samples to file")
  @Nullable
  public File dumpToFile(
          @JmxExport(value = "fileID", description = "the ID that will be part of the file name")
          @Nullable final String id) throws IOException {
    ProfileData data = getAndResetProfileSamples();
    if (data == null) {
      return null;
    }
    return persister.persist(data.getSamples(), id, data.getFrom(), data.getTo()).toFile();
  }

  /**
   * Dump  collected samples to disk.
   * @param destinationFolder the destination folder.
   * @param pbaseFileName file name base, will be URLEncoded.
   * The appropriate extension will be added depending on situation.
   * either ssdump2 or ssdump3 will be used depending on the number of sample aggregate groups.
   * additionally .gz will be added if compression is enabled.
   * in case of ssdump2 the label will be appended to the file name like _[label].ssdump2
   *
   * @return the saved file, or null if no collected samples to save.
   * @throws IOException
   */
  @Nullable
  @SuppressFBWarnings("PATH_TRAVERSAL_IN") // not possible the provided ID is validated for path separators.
  public File dumpToFile(@Nonnull final File destinationFolder, final String pbaseFileName) throws IOException {
    ProfileData data = getAndResetProfileSamples();
    if (data == null) {
      return null;
    }
    return persister.withBaseFileName(destinationFolder.toPath(), pbaseFileName)
           .persist(data.getSamples(), null, data.getFrom(), data.getTo()).toFile();
  }

  @JmxExport(description = "stop stack sampling")
  public void stop() throws InterruptedException {
    Future<?> toCancel = null;
    synchronized (sync) {
      if (!stopped) {
        stopped = true;
        toCancel = samplerFuture;
      }
    }
    if (toCancel != null) {
      try {
        toCancel.get(dumpTimeNanos * 3, TimeUnit.NANOSECONDS);
      } catch (TimeoutException ex) {
        toCancel.cancel(true);
        throw new Spf4jProfilerException(ex);
      } catch (ExecutionException ex) {
        throw new Spf4jProfilerException(ex);
      }
    }
  }

  @JmxExport(description = "stack sample time in milliseconds")
  public int getSampleTimeMillis() {
    return (int) TimeUnit.NANOSECONDS.toMillis(sampleTimeNanos);
  }

  @JmxExport
  public void setSampleTimeMillis(final int sampleTimeMillis) {
    this.sampleTimeNanos = (int) TimeUnit.MILLISECONDS.toNanos(sampleTimeMillis);
  }

  @JmxExport(description = "is the stack sampling stopped")
  public boolean isStopped() {
    return stopped;
  }

  @JmxExport(description = "clear in memory collected stack samples")
  public void clear() {
    synchronized (sync) {
      stackCollector.getCollectionsAndReset();
    }
  }

  @JmxExport
  public void flushPersister() throws IOException {
    this.persister.flush();
  }

  public Map<String, SampleNode> getStackCollectionsAndReset() {
    synchronized (sync) {
      if (stackCollector == null) {
        return Collections.EMPTY_MAP;
      }
      return stackCollector.getCollectionsAndReset();
    }
  }

  public Map<String, SampleNode> getStackCollections() {
    synchronized (sync) {
      if (stackCollector == null) {
        return Collections.EMPTY_MAP;
      }
      return stackCollector.getCollections();
    }
  }

  @PreDestroy
  public void dispose() throws InterruptedException, IOException {
    try {
      stop();
    } finally {
      Registry.unregister(this);
      this.persister.close();
    }
  }

  @JmxExport(description = "interval in milliseconds to save stack stamples periodically")
  public int getDumpTimeMillis() {
    return (int) TimeUnit.NANOSECONDS.toMillis(dumpTimeNanos);
  }

  @JmxExport
  public void setDumpTimeMillis(final int dumpTimeMillis) {
    this.dumpTimeNanos = TimeUnit.MILLISECONDS.toNanos(dumpTimeMillis);
  }

  @JmxExport
  public String getFilePrefix() {
    return persister.getBaseFileName();
  }

  @JmxExport
  public String getDumpFolder() {
    return persister.getTargetPath().toString();
  }

  @JmxExport
  @SuppressForbiden // need to use an openType
  public Date getLastDumpTime() {
    return new Date(Timing.getCurrentTiming().fromNanoTimeToEpochMillis(lastDumpTimeNanos));
  }

  @JmxExport
  @SuppressForbiden // need to use an openType
  public Instant getLastDumpInstant() {
    return Timing.getCurrentTiming().fromNanoTimeToInstant(lastDumpTimeNanos);
  }

}