diff --git a/hbase-http/pom.xml b/hbase-http/pom.xml index 6168b891791f..c5e98783bd9b 100644 --- a/hbase-http/pom.xml +++ b/hbase-http/pom.xml @@ -232,6 +232,12 @@ + + tools.profiler + async-profiler + ${async-profiler.version} + true + @@ -320,6 +326,16 @@ + + async-profiler + + + tools.profiler + async-profiler + ${async-profiler.version} + + + build-with-jdk11 diff --git a/hbase-http/src/main/java/org/apache/hadoop/hbase/http/HttpServer.java b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/HttpServer.java index fe2a9a48c210..786f930e7040 100644 --- a/hbase-http/src/main/java/org/apache/hadoop/hbase/http/HttpServer.java +++ b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/HttpServer.java @@ -155,6 +155,8 @@ public class HttpServer implements FilterContainer { "hbase.security.authentication.ui.config.protected"; public static final String HTTP_UI_NO_CACHE_ENABLE_KEY = "hbase.http.filter.no-store.enable"; public static final boolean HTTP_PRIVILEGED_CONF_DEFAULT = false; + public static final String PROFILER_ENABLED_KEY = "hbase.profiler.enabled"; + public static final boolean PROFILER_ENABLED_DEFAULT = true; // The ServletContext attribute where the daemon Configuration // gets stored. @@ -871,8 +873,12 @@ protected void addDefaultServlets(ContextHandlerCollection contexts, Configurati } else { addUnprivilegedServlet("conf", "/conf", ConfServlet.class); } - final String asyncProfilerHome = ProfileServlet.getAsyncProfilerHome(); - if (asyncProfilerHome != null && !asyncProfilerHome.trim().isEmpty()) { + + if (!conf.getBoolean(PROFILER_ENABLED_KEY, PROFILER_ENABLED_DEFAULT)) { + addUnprivilegedServlet("prof", "/prof", ProfileServlet.DisabledServlet.class); + LOG.info("Profiler disabled by configuration ({}=false). Disabling /prof endpoint.", + PROFILER_ENABLED_KEY); + } else if (ProfileServlet.isAvailable()) { addPrivilegedServlet("prof", "/prof", ProfileServlet.class); Path tmpDir = Paths.get(ProfileServlet.OUTPUT_DIR); if (Files.notExists(tmpDir)) { @@ -884,8 +890,8 @@ protected void addDefaultServlets(ContextHandlerCollection contexts, Configurati genCtx.setDisplayName("prof-output-hbase"); } else { addUnprivilegedServlet("prof", "/prof", ProfileServlet.DisabledServlet.class); - LOG.info("ASYNC_PROFILER_HOME environment variable and async.profiler.home system property " - + "not specified. Disabling /prof endpoint."); + LOG.info("async-profiler not available (no library on classpath and ASYNC_PROFILER_HOME " + + "not set). Disabling /prof endpoint."); } /* register metrics servlets */ diff --git a/hbase-http/src/main/java/org/apache/hadoop/hbase/http/LibraryBackend.java b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/LibraryBackend.java new file mode 100644 index 000000000000..5c9ccc9e4bd0 --- /dev/null +++ b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/LibraryBackend.java @@ -0,0 +1,58 @@ +/* + * 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.http; + +import java.io.File; +import java.io.IOException; +import one.profiler.AsyncProfiler; +import org.apache.yetus.audience.InterfaceAudience; + +/** + * Backend that uses the async-profiler Java API (in-process). Requires the + * {@code tools.profiler:async-profiler} JAR and its native library on the classpath. + *

+ * This class is intentionally isolated in its own file so that the JVM never loads + * {@code one.profiler.AsyncProfiler} on systems where the JAR is absent. It is only instantiated + * reflectively from {@link ProfilerBackend#detect} after confirming the class is resolvable. + */ +@InterfaceAudience.Private +final class LibraryBackend implements ProfilerBackend { + + @Override + public String executeStart(ProfileServlet.ProfileRequest request, File outputFile) + throws IOException { + String cmd = ProfilerCommandMapper.toLibraryStartCommand(request); + return AsyncProfiler.getInstance().execute(cmd); + } + + @Override + public String executeStop(ProfileServlet.ProfileRequest request, File outputFile) + throws IOException { + String cmd = ProfilerCommandMapper.toLibraryStopCommand(request, outputFile); + return AsyncProfiler.getInstance().execute(cmd); + } + + @Override + public void destroy() { + try { + AsyncProfiler.getInstance().execute("stop"); + } catch (Exception e) { + // ignored — profiler may not have been active at shutdown time + } + } +} diff --git a/hbase-http/src/main/java/org/apache/hadoop/hbase/http/ProfileServlet.java b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/ProfileServlet.java index ac5a55138fc4..6b623b148ea6 100644 --- a/hbase-http/src/main/java/org/apache/hadoop/hbase/http/ProfileServlet.java +++ b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/ProfileServlet.java @@ -19,11 +19,9 @@ import java.io.File; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; -import java.nio.file.Path; import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.Lock; @@ -31,13 +29,10 @@ import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; -import org.apache.hadoop.hbase.util.ProcessUtils; import org.apache.yetus.audience.InterfaceAudience; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.apache.hbase.thirdparty.com.google.common.base.Joiner; - /** * Servlet that runs async-profiler as web-endpoint. Following options from async-profiler can be * specified as query paramater. // -e event profiling event: cpu|alloc|lock|cache-misses etc. // -d @@ -68,14 +63,18 @@ public class ProfileServlet extends HttpServlet { private static final String ALLOWED_METHODS = "GET"; private static final String ACCESS_CONTROL_ALLOW_ORIGIN = "Access-Control-Allow-Origin"; private static final String CONTENT_TYPE_TEXT = "text/plain; charset=utf-8"; - private static final String ASYNC_PROFILER_HOME_ENV = "ASYNC_PROFILER_HOME"; - private static final String ASYNC_PROFILER_HOME_SYSTEM_PROPERTY = "async.profiler.home"; - private static final String OLD_PROFILER_SCRIPT = "profiler.sh"; - private static final String PROFILER_SCRIPT = "asprof"; private static final int DEFAULT_DURATION_SECONDS = 10; private static final AtomicInteger ID_GEN = new AtomicInteger(0); static final String OUTPUT_DIR = System.getProperty("java.io.tmpdir") + "/prof-output-hbase"; + private static final String ASYNC_PROFILER_HOME_ENV = "ASYNC_PROFILER_HOME"; + private static final String ASYNC_PROFILER_HOME_SYSTEM_PROPERTY = "async.profiler.home"; + + // Cached backend detection result — computed once at class-load time so that isAvailable() + // and the default constructor do not each pay the reflective detection cost. + private static final ProfilerBackend DETECTED_BACKEND = + ProfilerBackend.detect(getAsyncProfilerHome()); + enum Event { CPU("cpu"), WALL("wall"), @@ -133,49 +132,131 @@ enum Output { @edu.umd.cs.findbugs.annotations.SuppressWarnings(value = "SE_TRANSIENT_FIELD_NOT_RESTORED", justification = "This class is never serialized nor restored.") private transient Lock profilerLock = new ReentrantLock(); - private transient volatile Process process; - private String asyncProfilerHome; - private Integer pid; + private transient volatile boolean profiling; + private final long currentPid = ProcessHandle.current().pid(); + @edu.umd.cs.findbugs.annotations.SuppressWarnings(value = "SE_BAD_FIELD", + justification = "This class is never serialized nor restored.") + private final ProfilerBackend backend; + + public static final class ProfileRequest { + private final int duration; + private final Output output; + private final Event event; + private final Long interval; + private final Integer jstackDepth; + private final Long bufsize; + private final boolean thread; + private final boolean simple; + private final Integer width; + private final Integer height; + private final Double minwidth; + private final boolean reverse; + private final int refreshDelay; + private final Integer pid; + + private ProfileRequest(int duration, Output output, Event event, Long interval, + Integer jstackDepth, Long bufsize, boolean thread, boolean simple, Integer width, + Integer height, Double minwidth, boolean reverse, int refreshDelay, Integer pid) { + this.duration = duration; + this.output = output; + this.event = event; + this.interval = interval; + this.jstackDepth = jstackDepth; + this.bufsize = bufsize; + this.thread = thread; + this.simple = simple; + this.width = width; + this.height = height; + this.minwidth = minwidth; + this.reverse = reverse; + this.refreshDelay = refreshDelay; + this.pid = pid; + } - public ProfileServlet() { - this.asyncProfilerHome = getAsyncProfilerHome(); - this.pid = ProcessUtils.getPid(); - LOG.info("Servlet process PID: " + pid + " asyncProfilerHome: " + asyncProfilerHome); - } + public int getDuration() { + return duration; + } - @Override - protected void doGet(final HttpServletRequest req, final HttpServletResponse resp) - throws IOException { - if (!HttpServer.isInstrumentationAccessAllowed(getServletContext(), req, resp)) { - resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - setResponseHeader(resp); - resp.getWriter().write("Unauthorized: Instrumentation access is not allowed!"); - return; + public Output getOutput() { + return output; } - // make sure async profiler home is set - if (asyncProfilerHome == null || asyncProfilerHome.trim().isEmpty()) { - resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); - setResponseHeader(resp); - resp.getWriter() - .write("ASYNC_PROFILER_HOME env is not set.\n\n" - + "Please ensure the prerequisites for the Profiler Servlet have been installed and the\n" - + "environment is properly configured. For more information please see\n" - + "https://hbase.apache.org/docs/profiler\n"); - return; + public Event getEvent() { + return event; } - // if pid is explicitly specified, use it else default to current process - pid = getInteger(req, "pid", pid); + public Long getInterval() { + return interval; + } - // if pid is not specified in query param and if current process pid cannot be determined - if (pid == null) { - resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); - setResponseHeader(resp); - resp.getWriter() - .write("'pid' query parameter unspecified or unable to determine PID of current process."); - return; + public Integer getJstackDepth() { + return jstackDepth; + } + + public Long getBufsize() { + return bufsize; + } + + public boolean isThread() { + return thread; + } + + public boolean isSimple() { + return simple; + } + + public Integer getWidth() { + return width; + } + + public Integer getHeight() { + return height; + } + + public Double getMinwidth() { + return minwidth; + } + + public boolean isReverse() { + return reverse; + } + + public int getRefreshDelay() { + return refreshDelay; + } + + public Integer getPid() { + return pid; + } + } + + public ProfileServlet() { + this.backend = DETECTED_BACKEND; + LOG.info("ProfileServlet initialized with backend: {}", + backend != null ? backend.getClass().getSimpleName() : "none"); + } + + // visible for testing + ProfileServlet(ProfilerBackend backend) { + this.backend = backend; + } + + static String getAsyncProfilerHome() { + String home = System.getenv(ASYNC_PROFILER_HOME_ENV); + if (home == null || home.trim().isEmpty()) { + home = System.getProperty(ASYNC_PROFILER_HOME_SYSTEM_PROPERTY); } + return home; + } + + public static boolean isAvailable() { + return DETECTED_BACKEND != null; + } + + public ProfileRequest parseProfileRequest(final HttpServletRequest req) { + // Note: when using in-process async-profiler Java API, we can only profile this JVM. + // We keep the pid parameter for API compatibility, but do not support external processes. + Integer requestedPid = getInteger(req, "pid", null); final int duration = getInteger(req, "duration", DEFAULT_DURATION_SECONDS); final Output output = getOutput(req); @@ -189,109 +270,156 @@ protected void doGet(final HttpServletRequest req, final HttpServletResponse res final Integer height = getInteger(req, "height", null); final Double minwidth = getMinWidth(req); final boolean reverse = req.getParameterMap().containsKey("reverse"); + int refreshDelay = getInteger(req, "refreshDelay", 0); + + return new ProfileRequest(duration, output, event, interval, jstackDepth, bufsize, thread, + simple, width, height, minwidth, reverse, refreshDelay, requestedPid); + } + + protected String executeStart(ProfileRequest request, File outputFile) throws IOException { + return backend.executeStart(request, outputFile); + } + + protected String executeStop(ProfileRequest request, File outputFile) throws IOException { + return backend.executeStop(request, outputFile); + } - if (process == null || !process.isAlive()) { + @Override + protected void doGet(final HttpServletRequest req, final HttpServletResponse resp) + throws IOException { + if (!checkInstrumentationAccess(req, resp)) { + return; + } + + final ProfileRequest request = parseProfileRequest(req); + + // We keep the pid parameter for backward compatibility but only support profiling this JVM. + if (request.getPid() != null && request.getPid().longValue() != currentPid) { + writeError(resp, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "The 'pid' parameter is only supported for the current process when using the " + + "embedded async-profiler library."); + return; + } + + if (profiling) { + writeError(resp, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "Another instance of profiler is already running."); + return; + } + + int lockTimeoutSecs = 3; + boolean locked = false; + try { + locked = profilerLock.tryLock(lockTimeoutSecs, TimeUnit.SECONDS); + if (!locked) { + writeError(resp, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "Unable to acquire lock. Another instance of profiler might be running."); + LOG.warn("Unable to acquire lock in " + lockTimeoutSecs + + " seconds. Another instance of profiler might be running."); + return; + } + + File outputFile = createOutputFile(request); + // Ensure the file exists so ProfileOutputServlet can poll until it is complete. + Files.write(outputFile.toPath(), new byte[0]); + + executeStart(request, outputFile); + profiling = true; + + startStopperThread(request.getDuration(), request, outputFile); + + writeAcceptedResponse(resp, request, outputFile); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOG.warn("Interrupted while acquiring profile lock.", e); + writeError(resp, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "Interrupted while acquiring profile lock."); + } catch (Error e) { + // Native library load failures (UnsatisfiedLinkError, glibc symbol mismatch, + // kernel perf_event disabled, seccomp policy, etc.) surface as Errors on first use. + LOG.warn("Profiler native library failed to load or execute", e); + writeError(resp, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "Profiler native library error: " + e.getMessage() + + ". Check that the async-profiler native library is compatible with this OS/kernel."); + } finally { + if (locked) { + profilerLock.unlock(); + } + } + } + + private void startStopperThread(final int durationSeconds, final ProfileRequest request, + final File outputFile) { + Thread t = new Thread(() -> { try { - int lockTimeoutSecs = 3; - if (profilerLock.tryLock(lockTimeoutSecs, TimeUnit.SECONDS)) { - try { - File outputFile = - new File(OUTPUT_DIR, "async-prof-pid-" + pid + "-" + event.name().toLowerCase() + "-" - + ID_GEN.incrementAndGet() + "." + output.name().toLowerCase()); - Files.createDirectories(Paths.get(OUTPUT_DIR)); - List cmd = new ArrayList<>(); - Path profilerScriptPath = Paths.get(asyncProfilerHome, "bin", PROFILER_SCRIPT); - if (!Files.exists(profilerScriptPath)) { - LOG.info( - "async-profiler script {} does not exist, fallback to use old script {}(version <= 2.9).", - PROFILER_SCRIPT, OLD_PROFILER_SCRIPT); - profilerScriptPath = Paths.get(asyncProfilerHome, OLD_PROFILER_SCRIPT); - } - cmd.add(profilerScriptPath.toString()); - cmd.add("-e"); - cmd.add(event.getInternalName()); - cmd.add("-d"); - cmd.add("" + duration); - cmd.add("-o"); - cmd.add(output.name().toLowerCase()); - cmd.add("-f"); - cmd.add(outputFile.getAbsolutePath()); - if (interval != null) { - cmd.add("-i"); - cmd.add(interval.toString()); - } - if (jstackDepth != null) { - cmd.add("-j"); - cmd.add(jstackDepth.toString()); - } - if (bufsize != null) { - cmd.add("-b"); - cmd.add(bufsize.toString()); - } - if (thread) { - cmd.add("-t"); - } - if (simple) { - cmd.add("-s"); - } - if (width != null) { - cmd.add("--width"); - cmd.add(width.toString()); - } - if (height != null) { - cmd.add("--height"); - cmd.add(height.toString()); - } - if (minwidth != null) { - cmd.add("--minwidth"); - cmd.add(minwidth.toString()); - } - if (reverse) { - cmd.add("--reverse"); - } - cmd.add(pid.toString()); - process = ProcessUtils.runCmdAsync(cmd); - - // set response and set refresh header to output location - setResponseHeader(resp); - resp.setStatus(HttpServletResponse.SC_ACCEPTED); - String relativeUrl = "/prof-output-hbase/" + outputFile.getName(); - resp.getWriter() - .write("Started [" + event.getInternalName() - + "] profiling. This page will automatically redirect to " + relativeUrl + " after " - + duration + " seconds. " - + "If empty diagram and Linux 4.6+, see 'Basic Usage' section on the Async " - + "Profiler Home Page, https://github.com/jvm-profiling-tools/async-profiler." - + "\n\nCommand:\n" + Joiner.on(" ").join(cmd)); - - // to avoid auto-refresh by ProfileOutputServlet, refreshDelay can be specified - // via url param - int refreshDelay = getInteger(req, "refreshDelay", 0); - - // instead of sending redirect, set auto-refresh so that browsers will refresh - // with redirected url - resp.setHeader("Refresh", (duration + refreshDelay) + ";" + relativeUrl); - resp.getWriter().flush(); - } finally { - profilerLock.unlock(); - } - } else { - setResponseHeader(resp); - resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); - resp.getWriter() - .write("Unable to acquire lock. Another instance of profiler might be running."); - LOG.warn("Unable to acquire lock in " + lockTimeoutSecs - + " seconds. Another instance of profiler might be running."); + TimeUnit.SECONDS.sleep(durationSeconds); + executeStop(request, outputFile); + } catch (Exception e) { + try { + Files.write(outputFile.toPath(), + ("Profiler failed: " + e.getMessage()).getBytes(StandardCharsets.UTF_8)); + } catch (IOException ioe) { + LOG.warn("Unable to write profiler error to output file", ioe); } - } catch (InterruptedException e) { - LOG.warn("Interrupted while acquiring profile lock.", e); - resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + LOG.warn("Profiler stop/dump failed", e); + } finally { + profiling = false; } - } else { + }, "ProfileServlet-stopper"); + t.setDaemon(true); + t.start(); + } + + private boolean checkInstrumentationAccess(final HttpServletRequest req, + final HttpServletResponse resp) throws IOException { + if (!HttpServer.isInstrumentationAccessAllowed(getServletContext(), req, resp)) { + resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED); setResponseHeader(resp); - resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); - resp.getWriter().write("Another instance of profiler is already running."); + resp.getWriter().write("Unauthorized: Instrumentation access is not allowed!"); + return false; } + return true; + } + + @Override + public void destroy() { + if (backend != null) { + backend.destroy(); + } + super.destroy(); + } + + private void writeError(final HttpServletResponse resp, final int status, final String message) + throws IOException { + resp.setStatus(status); + setResponseHeader(resp); + resp.getWriter().write(message); + } + + private File createOutputFile(final ProfileRequest request) throws IOException { + final long pid = request.getPid() != null ? request.getPid().longValue() : currentPid; + File outputFile = + new File(OUTPUT_DIR, "async-prof-pid-" + pid + "-" + request.getEvent().name().toLowerCase() + + "-" + ID_GEN.incrementAndGet() + "." + request.getOutput().name().toLowerCase()); + Files.createDirectories(Paths.get(OUTPUT_DIR)); + return outputFile; + } + + private void writeAcceptedResponse(final HttpServletResponse resp, final ProfileRequest request, + final File outputFile) throws IOException { + setResponseHeader(resp); + resp.setStatus(HttpServletResponse.SC_ACCEPTED); + String relativeUrl = "/prof-output-hbase/" + outputFile.getName(); + resp.getWriter() + .write("Started [" + request.getEvent().getInternalName() + + "] profiling. This page will automatically redirect to " + relativeUrl + " after " + + request.getDuration() + " seconds. " + + "If empty diagram and Linux 4.6+, see 'Basic Usage' section on the Async " + + "Profiler Home Page, https://github.com/jvm-profiling-tools/async-profiler."); + + resp.setHeader("Refresh", + (request.getDuration() + request.getRefreshDelay()) + ";" + relativeUrl); + resp.getWriter().flush(); } private Integer getInteger(final HttpServletRequest req, final String param, @@ -358,16 +486,6 @@ static void setResponseHeader(final HttpServletResponse response) { response.setContentType(CONTENT_TYPE_TEXT); } - static String getAsyncProfilerHome() { - String asyncProfilerHome = System.getenv(ASYNC_PROFILER_HOME_ENV); - // if ENV is not set, see if -Dasync.profiler.home=/path/to/async/profiler/home is set - if (asyncProfilerHome == null || asyncProfilerHome.trim().isEmpty()) { - asyncProfilerHome = System.getProperty(ASYNC_PROFILER_HOME_SYSTEM_PROPERTY); - } - - return asyncProfilerHome; - } - public static class DisabledServlet extends HttpServlet { private static final long serialVersionUID = 1L; diff --git a/hbase-http/src/main/java/org/apache/hadoop/hbase/http/ProfilerBackend.java b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/ProfilerBackend.java new file mode 100644 index 000000000000..98fcc85c25a7 --- /dev/null +++ b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/ProfilerBackend.java @@ -0,0 +1,134 @@ +/* + * 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.http; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import org.apache.hadoop.hbase.util.ProcessUtils; +import org.apache.yetus.audience.InterfaceAudience; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Abstraction over async-profiler execution. Implementations handle either the in-process Java API + * ({@link LibraryBackend}, when the maven dependency is on the classpath) or the external binary + * ({@link BinaryBackend}, when {@code ASYNC_PROFILER_HOME} is set). + *

+ * This file deliberately contains no import of {@code one.profiler.AsyncProfiler}. That import is + * isolated in {@link LibraryBackend} so that binary-only deployments never trigger a + * {@code NoClassDefFoundError} when this class is loaded. + */ +@InterfaceAudience.Private +interface ProfilerBackend { + + /** + * Executes a profiling start command and returns the profiler's response. + */ + String executeStart(ProfileServlet.ProfileRequest request, File outputFile) throws IOException; + + /** + * Executes a profiling stop/dump command. + */ + String executeStop(ProfileServlet.ProfileRequest request, File outputFile) throws IOException; + + /** + * Cleans up any resources (e.g. kills a running process). Called on servlet destroy. + */ + default void destroy() { + } + + /** + * Detects which backend is available. Prefers {@link LibraryBackend} over {@link BinaryBackend}. + * Returns {@code null} if neither is available. + *

+ * When both the library and a binary home are available, {@link LibraryBackend} is preferred and + * {@code ASYNC_PROFILER_HOME} is ignored. + *

+ * {@link LibraryBackend} is instantiated reflectively so that its class — and therefore + * {@code one.profiler.AsyncProfiler} — is never loaded on systems where the JAR is absent. + */ + static ProfilerBackend detect(String asyncProfilerHome) { + // 1. Try in-process Java API (optional maven dependency). + // Use Class.forName to probe without triggering a hard class-load of LibraryBackend, + // which would pull in one.profiler.AsyncProfiler and fail on binary-only systems. + try { + // Use the classloader that loaded this class so that isolated-classloader tests + // (which block one.profiler.*) correctly see the library as absent. + ClassLoader cl = ProfilerBackend.class.getClassLoader(); + Class.forName("one.profiler.AsyncProfiler", false, cl); + // AsyncProfiler resolved — now safe to load LibraryBackend through the same loader + return (ProfilerBackend) Class + .forName("org.apache.hadoop.hbase.http.LibraryBackend", true, cl).getDeclaredConstructor() + .newInstance(); + } catch (UnsatisfiedLinkError | ReflectiveOperationException e) { + // library not on classpath or native lib failed to load + } + // 2. Try external binary + if (asyncProfilerHome != null && !asyncProfilerHome.trim().isEmpty()) { + return new BinaryBackend(asyncProfilerHome); + } + return null; + } +} + +/** + * Backend that invokes the async-profiler binary ({@code asprof} / {@code profiler.sh}) as an + * external process. Requires {@code ASYNC_PROFILER_HOME} to be set. + */ +@InterfaceAudience.Private +final class BinaryBackend implements ProfilerBackend { + + private static final Logger LOG = LoggerFactory.getLogger(BinaryBackend.class); + + private final String profilerHome; + private volatile Process process; + + BinaryBackend(String profilerHome) { + this.profilerHome = profilerHome; + } + + @Override + public String executeStart(ProfileServlet.ProfileRequest request, File outputFile) + throws IOException { + Integer pid = request.getPid() != null ? request.getPid() : ProcessUtils.getPid(); + if (pid == null) { + throw new IOException("Unable to determine PID of current process. " + + "Set the JVM_PID environment variable or pass '?pid=' explicitly."); + } + List cmd = ProfilerCommandMapper.toCliCommand(request, outputFile, profilerHome, pid); + process = ProcessUtils.runCmdAsync(cmd); + return ""; + } + + @Override + public String executeStop(ProfileServlet.ProfileRequest request, File outputFile) + throws IOException { + // The binary runs for the requested duration and exits on its own — nothing to do here. + return ""; + } + + @Override + public void destroy() { + Process p = process; + if (p != null && p.isAlive()) { + LOG.info("Destroying async-profiler process on servlet shutdown."); + p.destroy(); + } + } +} diff --git a/hbase-http/src/main/java/org/apache/hadoop/hbase/http/ProfilerCommandMapper.java b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/ProfilerCommandMapper.java new file mode 100644 index 000000000000..da5cb4ddab2b --- /dev/null +++ b/hbase-http/src/main/java/org/apache/hadoop/hbase/http/ProfilerCommandMapper.java @@ -0,0 +1,171 @@ +/* + * 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.http; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import org.apache.yetus.audience.InterfaceAudience; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Utility class that maps {@link ProfileServlet.ProfileRequest} to async-profiler commands in both + * the in-process Java API format (comma-separated string) and the CLI format (argument list). + */ +@InterfaceAudience.Private +final class ProfilerCommandMapper { + + private static final Logger LOG = LoggerFactory.getLogger(ProfilerCommandMapper.class); + + private static final String PROFILER_SCRIPT = "asprof"; + private static final String OLD_PROFILER_SCRIPT = "profiler.sh"; + + private ProfilerCommandMapper() { + } + + /** + * Builds the start command string for the async-profiler Java API. Format: + * {@code start,event=[,interval=N][,jstackdepth=N][,bufsize=N][,threads][,simple]} + */ + static String toLibraryStartCommand(ProfileServlet.ProfileRequest request) { + StringBuilder sb = new StringBuilder("start"); + sb.append(",event=").append(request.getEvent().getInternalName()); + appendOption(sb, "interval", request.getInterval()); + appendOption(sb, "jstackdepth", request.getJstackDepth()); + appendOption(sb, "bufsize", request.getBufsize()); + if (request.isThread()) { + sb.append(",threads"); + } + if (request.isSimple()) { + sb.append(",simple"); + } + return sb.toString(); + } + + /** + * Builds the stop command string for the async-profiler Java API. Format: + * {@code stop,file=,format=[,width=N][,height=N][,minwidth=N][,reverse]} + */ + static String toLibraryStopCommand(ProfileServlet.ProfileRequest request, File outputFile) { + StringBuilder sb = new StringBuilder("stop"); + sb.append(",file=").append(outputFile.getAbsolutePath()); + sb.append(",format=").append(toFormatString(request.getOutput())); + appendOption(sb, "width", request.getWidth()); + appendOption(sb, "height", request.getHeight()); + appendOption(sb, "minwidth", request.getMinwidth()); + if (request.isReverse()) { + sb.append(",reverse"); + } + return sb.toString(); + } + + /** + * Builds the CLI argument list for invoking the async-profiler binary (asprof / profiler.sh). + * Locates the script under {@code /bin/asprof}, falling back to + * {@code /profiler.sh} for older installations. + */ + static List toCliCommand(ProfileServlet.ProfileRequest request, File outputFile, + String profilerHome, Integer pid) { + List cmd = new ArrayList<>(); + Path profilerScriptPath = Paths.get(profilerHome, "bin", PROFILER_SCRIPT); + if (!Files.exists(profilerScriptPath)) { + LOG.info("async-profiler script {} does not exist, falling back to {}(version <= 2.9).", + PROFILER_SCRIPT, OLD_PROFILER_SCRIPT); + profilerScriptPath = Paths.get(profilerHome, OLD_PROFILER_SCRIPT); + } + cmd.add(profilerScriptPath.toString()); + cmd.add("-e"); + cmd.add(request.getEvent().getInternalName()); + cmd.add("-d"); + cmd.add(String.valueOf(request.getDuration())); + cmd.add("-o"); + cmd.add(request.getOutput().name().toLowerCase()); + cmd.add("-f"); + cmd.add(outputFile.getAbsolutePath()); + if (request.getInterval() != null) { + cmd.add("-i"); + cmd.add(request.getInterval().toString()); + } + if (request.getJstackDepth() != null) { + cmd.add("-j"); + cmd.add(request.getJstackDepth().toString()); + } + if (request.getBufsize() != null) { + cmd.add("-b"); + cmd.add(request.getBufsize().toString()); + } + if (request.isThread()) { + cmd.add("-t"); + } + if (request.isSimple()) { + cmd.add("-s"); + } + if (request.getWidth() != null) { + cmd.add("--width"); + cmd.add(request.getWidth().toString()); + } + if (request.getHeight() != null) { + cmd.add("--height"); + cmd.add(request.getHeight().toString()); + } + if (request.getMinwidth() != null) { + cmd.add("--minwidth"); + cmd.add(request.getMinwidth().toString()); + } + if (request.isReverse()) { + cmd.add("--reverse"); + } + cmd.add(pid.toString()); + return cmd; + } + + /** + * Maps the {@link ProfileServlet.Output} enum to the format string used by both backends. + */ + static String toFormatString(ProfileServlet.Output output) { + switch (output) { + case SUMMARY: + return "summary"; + case TRACES: + return "traces"; + case FLAT: + return "flat"; + case COLLAPSED: + return "collapsed"; + case TREE: + return "tree"; + case JFR: + return "jfr"; + case SVG: + return "svg"; + case HTML: + default: + return "html"; + } + } + + private static void appendOption(StringBuilder sb, String key, Object value) { + if (value != null) { + sb.append(',').append(key).append('=').append(value); + } + } +} diff --git a/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestHttpServer.java b/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestHttpServer.java index 1b73f302602a..69dac1baa560 100644 --- a/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestHttpServer.java +++ b/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestHttpServer.java @@ -675,4 +675,22 @@ public void testHttpMethods() throws Exception { conn.connect(); assertEquals(HttpURLConnection.HTTP_FORBIDDEN, conn.getResponseCode()); } + + @Test + public void testProfilerDisabledByConfig() throws Exception { + Configuration conf = new Configuration(); + conf.setBoolean(HttpServer.PROFILER_ENABLED_KEY, false); + HttpServer myServer = new HttpServer.Builder().setName("test") + .addEndpoint(new URI("http://localhost:0")).setFindPort(true).setConf(conf).build(); + myServer.setAttribute(HttpServer.CONF_CONTEXT_ATTRIBUTE, conf); + myServer.start(); + try { + URL profUrl = + new URL("http://" + NetUtils.getHostPortString(myServer.getConnectorAddress(0)) + "/prof"); + HttpURLConnection conn = (HttpURLConnection) profUrl.openConnection(); + assertEquals(HttpURLConnection.HTTP_INTERNAL_ERROR, conn.getResponseCode()); + } finally { + myServer.stop(); + } + } } diff --git a/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestProfileServlet.java b/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestProfileServlet.java new file mode 100644 index 000000000000..c3383b922d70 --- /dev/null +++ b/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestProfileServlet.java @@ -0,0 +1,184 @@ +/* + * 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.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import javax.servlet.ServletConfig; +import javax.servlet.ServletContext; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.hbase.testclassification.MiscTests; +import org.apache.hadoop.hbase.testclassification.SmallTests; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +@Tag(MiscTests.TAG) +@Tag(SmallTests.TAG) +public class TestProfileServlet { + + // ---- parseProfileRequest ---- + + @Test + public void testParseProfileRequestDefaults() { + ProfileServlet servlet = new ProfileServlet(null); + HttpServletRequest req = mockRequest(Collections.emptyMap(), "pid", null, "duration", null, + "output", null, "event", null, "interval", null, "jstackdepth", null, "bufsize", null, + "width", null, "height", null, "minwidth", null, "refreshDelay", null); + + ProfileServlet.ProfileRequest parsed = servlet.parseProfileRequest(req); + assertNull(parsed.getPid()); + assertEquals(10, parsed.getDuration()); + assertEquals(ProfileServlet.Event.CPU, parsed.getEvent()); + assertEquals(ProfileServlet.Output.HTML, parsed.getOutput()); + assertFalse(parsed.isThread()); + assertFalse(parsed.isSimple()); + assertFalse(parsed.isReverse()); + } + + @Test + public void testParseProfileRequestAllOptions() { + Map flags = new HashMap<>(); + flags.put("thread", new String[] { "" }); + flags.put("simple", new String[] { "" }); + flags.put("reverse", new String[] { "" }); + + ProfileServlet servlet = new ProfileServlet(null); + HttpServletRequest req = mockRequest(flags, "pid", "42", "duration", "60", "output", "tree", + "event", "alloc", "interval", "1000", "jstackdepth", "256", "bufsize", "100000", "width", + "1200", "height", "16", "minwidth", "0.5", "refreshDelay", "3"); + + ProfileServlet.ProfileRequest parsed = servlet.parseProfileRequest(req); + assertEquals(42, parsed.getPid()); + assertEquals(60, parsed.getDuration()); + assertEquals(ProfileServlet.Output.TREE, parsed.getOutput()); + assertEquals(ProfileServlet.Event.ALLOC, parsed.getEvent()); + assertEquals(1000L, parsed.getInterval()); + assertEquals(256, parsed.getJstackDepth()); + assertEquals(100000L, parsed.getBufsize()); + assertEquals(1200, parsed.getWidth()); + assertEquals(16, parsed.getHeight()); + assertEquals(0.5, parsed.getMinwidth()); + assertEquals(3, parsed.getRefreshDelay()); + assertTrue(parsed.isThread()); + assertTrue(parsed.isSimple()); + assertTrue(parsed.isReverse()); + } + + // ---- doGet ---- + + @Test + public void testDoGetSetsRefreshHeaderAndCallsBackend() throws Exception { + ProfilerBackend mockBackend = Mockito.mock(ProfilerBackend.class); + Mockito.when(mockBackend.executeStart(Mockito.any(), Mockito.any())).thenReturn("OK"); + + ProfileServlet servlet = new ProfileServlet(mockBackend); + servlet.init(mockServletConfig()); + + HttpServletRequest req = mockRequest(Collections.emptyMap(), "pid", null, "duration", "1", + "refreshDelay", "2", "output", null, "event", null, "interval", null, "jstackdepth", null, + "bufsize", null, "width", null, "height", null, "minwidth", null); + + HttpServletResponse resp = Mockito.mock(HttpServletResponse.class); + StringWriter body = new StringWriter(); + Mockito.when(resp.getWriter()).thenReturn(new PrintWriter(body)); + + servlet.doGet(req, resp); + + Mockito.verify(mockBackend).executeStart(Mockito.any(), Mockito.any()); + Mockito.verify(resp).setStatus(HttpServletResponse.SC_ACCEPTED); + + ArgumentCaptor refreshCaptor = ArgumentCaptor.forClass(String.class); + Mockito.verify(resp).setHeader(Mockito.eq("Refresh"), refreshCaptor.capture()); + assertTrue(refreshCaptor.getValue().startsWith("3;")); + assertTrue(refreshCaptor.getValue().contains("/prof-output-hbase/")); + } + + // ---- isAvailable / getAsyncProfilerHome ---- + + @Test + public void testIsAvailableDetectReturnsBackendWhenLibraryPresent() { + // async-profiler is on the test classpath (compile-time optional dep present in tests), + // so detect() returns LibraryBackend even with null home. + assertNotNull(ProfilerBackend.detect(null)); + } + + @Test + public void testGetAsyncProfilerHomeSystemProperty() { + String key = "async.profiler.home"; + String prev = System.getProperty(key); + try { + System.setProperty(key, "/tmp/fake-profiler"); + assertEquals("/tmp/fake-profiler", ProfileServlet.getAsyncProfilerHome()); + } finally { + if (prev == null) { + System.clearProperty(key); + } else { + System.setProperty(key, prev); + } + } + } + + // ---- DisabledServlet ---- + + @Test + public void testDisabledServletReturns500() throws Exception { + ProfileServlet.DisabledServlet disabled = new ProfileServlet.DisabledServlet(); + HttpServletRequest req = Mockito.mock(HttpServletRequest.class); + HttpServletResponse resp = Mockito.mock(HttpServletResponse.class); + StringWriter body = new StringWriter(); + Mockito.when(resp.getWriter()).thenReturn(new PrintWriter(body)); + + disabled.doGet(req, resp); + + Mockito.verify(resp).setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + assertTrue(body.toString().contains("profiler servlet was disabled")); + } + + // ---- helpers ---- + + private HttpServletRequest mockRequest(Map paramMap, String... kvPairs) { + HttpServletRequest req = Mockito.mock(HttpServletRequest.class); + Mockito.when(req.getParameterMap()).thenReturn(paramMap); + for (int i = 0; i < kvPairs.length; i += 2) { + Mockito.when(req.getParameter(kvPairs[i])).thenReturn(kvPairs[i + 1]); + } + return req; + } + + private ServletConfig mockServletConfig() throws Exception { + ServletContext ctx = Mockito.mock(ServletContext.class); + Mockito.when(ctx.getAttribute(HttpServer.CONF_CONTEXT_ATTRIBUTE)) + .thenReturn(new Configuration(false)); + ServletConfig config = Mockito.mock(ServletConfig.class); + Mockito.when(config.getServletContext()).thenReturn(ctx); + return config; + } +} diff --git a/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestProfilerBackend.java b/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestProfilerBackend.java new file mode 100644 index 000000000000..77709af7734b --- /dev/null +++ b/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestProfilerBackend.java @@ -0,0 +1,82 @@ +/* + * 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.http; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.apache.hadoop.hbase.testclassification.MiscTests; +import org.apache.hadoop.hbase.testclassification.SmallTests; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +@Tag(MiscTests.TAG) +@Tag(SmallTests.TAG) +public class TestProfilerBackend { + + @TempDir + Path tempDir; + + @Test + public void testDetectReturnsLibraryBackendWhenLibraryOnClasspath() { + // async-profiler is on the test classpath, so detect() always returns LibraryBackend + // regardless of home setting — library takes priority. + ProfilerBackend backend = ProfilerBackend.detect(null); + assertNotNull(backend); + assertInstanceOf(LibraryBackend.class, backend); + } + + @Test + public void testDetectReturnsBinaryBackendWhenHomeSet() throws Exception { + // Create a fake profiler home with bin/asprof so path check passes + Files.createDirectories(tempDir.resolve("bin")); + Files.createFile(tempDir.resolve("bin").resolve("asprof")); + + // The test classpath has the async-profiler JAR (optional compile dep), so detect() returns + // LibraryBackend here. BinaryBackend selection is verified under isolation by + // TestProfilerBackendIsolated.testDetectReturnsBinaryBackendWhenLibraryAbsentButHomeSet. + // This test simply asserts that a valid home always yields a non-null backend. + assertNotNull(ProfilerBackend.detect(tempDir.toString())); + } + + @Test + public void testBinaryBackendDetectReturnsNonNullWhenHomeProvided() { + // Any non-empty home string produces a non-null backend (LibraryBackend when JAR is present, + // BinaryBackend when absent). Both are valid — what matters is non-null. + assertNotNull(ProfilerBackend.detect("/fake/profiler/home")); + } + + @Test + public void testDetectPrefersLibraryWhenBothAvailable() { + // Library takes priority over binary home. Since the JAR is on the test classpath, + // detect() must return LibraryBackend even when a home is provided. + ProfilerBackend backend = ProfilerBackend.detect("/some/home"); + assertNotNull(backend); + assertInstanceOf(LibraryBackend.class, backend); + } + + @Test + public void testBinaryBackendDestroyDoesNotThrowWhenNoProcess() { + BinaryBackend backend = new BinaryBackend("/fake/home"); + // Should not throw when no process has been started + backend.destroy(); + } +} diff --git a/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestProfilerBackendIsolated.java b/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestProfilerBackendIsolated.java new file mode 100644 index 000000000000..3f455c53df96 --- /dev/null +++ b/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestProfilerBackendIsolated.java @@ -0,0 +1,163 @@ +/* + * 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.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import org.apache.hadoop.hbase.testclassification.MiscTests; +import org.apache.hadoop.hbase.testclassification.SmallTests; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Verifies {@link ProfilerBackend#detect} fallback behaviour when + * {@code one.profiler.AsyncProfiler} is absent from the classpath. + *

+ * Each test loads {@code ProfilerBackend} through a custom {@link ClassLoader} that blocks + * {@code one.profiler.*}, simulating a deployment where the async-profiler JAR was never packaged. + * This is the exact scenario for users who have async-profiler installed as a native binary + * ({@code ASYNC_PROFILER_HOME}) but are not allowed to bundle the JAR. + *

+ * The split of {@link LibraryBackend} into its own file is what makes this possible: + * {@code ProfilerBackend.class} carries no static reference to {@code AsyncProfiler}, so the + * isolated loader can load it without a {@code NoClassDefFoundError}. + */ +@Tag(MiscTests.TAG) +@Tag(SmallTests.TAG) +public class TestProfilerBackendIsolated { + + @TempDir + Path tempDir; + + /** + * When the library is absent AND no home is set, detect() must return null so that HttpServer + * registers DisabledServlet instead of crashing. + */ + @Test + public void testDetectReturnsNullWhenLibraryAbsentAndNoHome() throws Exception { + ClassLoader isolated = isolatedLoader(); + Method detect = detectMethod(isolated); + + assertNull(detect.invoke(null, (String) null)); + assertNull(detect.invoke(null, "")); + assertNull(detect.invoke(null, " ")); + } + + /** + * User has async-profiler installed as a native binary (ASYNC_PROFILER_HOME set, bin/asprof + * present) but no JAR on the classpath. detect() must return BinaryBackend. + */ + @Test + public void testDetectReturnsBinaryBackendWhenLibraryAbsentButHomeSet() throws Exception { + // Create a minimal fake profiler home with bin/asprof + Files.createDirectories(tempDir.resolve("bin")); + Files.createFile(tempDir.resolve("bin").resolve("asprof")); + + ClassLoader isolated = isolatedLoader(); + Method detect = detectMethod(isolated); + + Object backend = detect.invoke(null, tempDir.toString()); + assertNotNull(backend); + assertEquals("BinaryBackend", backend.getClass().getSimpleName()); + } + + /** + * When the library IS on the classpath (normal test classpath), detect() must return + * LibraryBackend regardless of whether a home is set — library takes priority. + */ + @Test + public void testDetectReturnsLibraryBackendWhenLibraryPresent() { + // Use real classpath — async-profiler JAR is present as optional compile dep in tests + ProfilerBackend backend = ProfilerBackend.detect(null); + assertNotNull(backend); + assertEquals("LibraryBackend", backend.getClass().getSimpleName()); + } + + /** + * Library present AND home set — LibraryBackend must still win (priority check). + */ + @Test + public void testDetectPrefersLibraryWhenBothPresent() throws Exception { + Files.createDirectories(tempDir.resolve("bin")); + Files.createFile(tempDir.resolve("bin").resolve("asprof")); + + ProfilerBackend backend = ProfilerBackend.detect(tempDir.toString()); + assertNotNull(backend); + assertEquals("LibraryBackend", backend.getClass().getSimpleName()); + } + + // ---- helpers ---- + + /** + * Returns a ClassLoader that: - blocks {@code one.profiler.*} entirely (simulates absent + * async-profiler JAR) - reloads {@code org.apache.hadoop.hbase.http.*} classes fresh (so + * LibraryBackend resolves its own imports through this loader and also sees one.profiler.* as + * absent) - delegates everything else to the parent + */ + private ClassLoader isolatedLoader() { + ClassLoader parent = getClass().getClassLoader(); + return new ClassLoader(parent) { + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.startsWith("one.profiler.")) { + throw new ClassNotFoundException("Simulated absent library: " + name); + } + // Force fresh load of our http package so LibraryBackend uses this loader + // (and therefore also sees one.profiler.* as absent when it tries to resolve it) + if (name.startsWith("org.apache.hadoop.hbase.http.")) { + Class c = findLoadedClass(name); + if (c != null) { + return c; + } + // Load bytes from parent, define in this loader + String path = name.replace('.', '/') + ".class"; + try (java.io.InputStream in = parent.getResourceAsStream(path)) { + if (in != null) { + byte[] bytes = in.readAllBytes(); + c = defineClass(name, bytes, 0, bytes.length); + if (resolve) { + resolveClass(c); + } + return c; + } + } catch (java.io.IOException e) { + throw new ClassNotFoundException(name, e); + } + } + return super.loadClass(name, resolve); + } + }; + } + + /** + * Loads {@code ProfilerBackend} through the given loader and returns its {@code detect(String)} + * method, made accessible across loader boundaries. + */ + private Method detectMethod(ClassLoader loader) throws Exception { + Class backendClass = loader.loadClass("org.apache.hadoop.hbase.http.ProfilerBackend"); + Method m = backendClass.getMethod("detect", String.class); + m.setAccessible(true); + return m; + } +} diff --git a/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestProfilerCommandMapper.java b/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestProfilerCommandMapper.java new file mode 100644 index 000000000000..71fc065f7c0e --- /dev/null +++ b/hbase-http/src/test/java/org/apache/hadoop/hbase/http/TestProfilerCommandMapper.java @@ -0,0 +1,208 @@ +/* + * 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.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.servlet.http.HttpServletRequest; +import org.apache.hadoop.hbase.testclassification.MiscTests; +import org.apache.hadoop.hbase.testclassification.SmallTests; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mockito; + +@Tag(MiscTests.TAG) +@Tag(SmallTests.TAG) +public class TestProfilerCommandMapper { + + @TempDir + Path tempDir; + + // ---- Library start command ---- + + @Test + public void testLibraryStartCommandDefaults() { + ProfileServlet.ProfileRequest req = parseRequest(Collections.emptyMap()); + String cmd = ProfilerCommandMapper.toLibraryStartCommand(req); + assertTrue(cmd.startsWith("start")); + assertTrue(cmd.contains("event=cpu")); + assertFalse(cmd.contains("interval")); + assertFalse(cmd.contains("threads")); + assertFalse(cmd.contains("simple")); + } + + @Test + public void testLibraryStartCommandAllOptions() { + Map flags = new HashMap<>(); + flags.put("thread", new String[] { "" }); + flags.put("simple", new String[] { "" }); + + ProfileServlet.ProfileRequest req = parseRequest(flags, "event", "alloc", "interval", "1000", + "jstackdepth", "256", "bufsize", "100000"); + String cmd = ProfilerCommandMapper.toLibraryStartCommand(req); + assertTrue(cmd.contains("event=alloc")); + assertTrue(cmd.contains("interval=1000")); + assertTrue(cmd.contains("jstackdepth=256")); + assertTrue(cmd.contains("bufsize=100000")); + assertTrue(cmd.contains("threads")); + assertTrue(cmd.contains("simple")); + } + + // ---- Library stop command ---- + + @Test + public void testLibraryStopCommand() throws IOException { + Map flags = new HashMap<>(); + flags.put("reverse", new String[] { "" }); + ProfileServlet.ProfileRequest req = + parseRequest(flags, "output", "html", "width", "1200", "height", "16", "minwidth", "0.5"); + + File outputFile = File.createTempFile("prof", ".html"); + outputFile.deleteOnExit(); + + String cmd = ProfilerCommandMapper.toLibraryStopCommand(req, outputFile); + assertTrue(cmd.startsWith("stop")); + assertTrue(cmd.contains("file=" + outputFile.getAbsolutePath())); + assertTrue(cmd.contains("format=html")); + assertTrue(cmd.contains("width=1200")); + assertTrue(cmd.contains("height=16")); + assertTrue(cmd.contains("minwidth=0.5")); + assertTrue(cmd.contains("reverse")); + } + + // ---- CLI command ---- + + @Test + public void testCliCommandDefaultScript() throws IOException { + // Create bin/asprof so the primary script path exists + Path binDir = Files.createDirectories(tempDir.resolve("bin")); + Files.createFile(binDir.resolve("asprof")); + + ProfileServlet.ProfileRequest req = + parseRequest(Collections.emptyMap(), "duration", "30", "output", "html"); + File outputFile = File.createTempFile("prof", ".html"); + outputFile.deleteOnExit(); + + List cmd = + ProfilerCommandMapper.toCliCommand(req, outputFile, tempDir.toString(), 1234); + assertEquals(tempDir.resolve("bin/asprof").toString(), cmd.get(0)); + assertTrue(cmd.contains("-e")); + assertTrue(cmd.contains("cpu")); + assertTrue(cmd.contains("-d")); + assertTrue(cmd.contains("30")); + assertTrue(cmd.contains("-o")); + assertTrue(cmd.contains("html")); + assertTrue(cmd.contains("-f")); + assertTrue(cmd.contains(outputFile.getAbsolutePath())); + assertTrue(cmd.contains("1234")); + } + + @Test + public void testCliCommandFallbackToOldScript() throws IOException { + // Do NOT create bin/asprof — only create profiler.sh as fallback + Files.createFile(tempDir.resolve("profiler.sh")); + + ProfileServlet.ProfileRequest req = parseRequest(Collections.emptyMap()); + File outputFile = File.createTempFile("prof", ".html"); + outputFile.deleteOnExit(); + + List cmd = + ProfilerCommandMapper.toCliCommand(req, outputFile, tempDir.toString(), 1234); + assertEquals(tempDir.resolve("profiler.sh").toString(), cmd.get(0)); + } + + @Test + public void testCliCommandAllOptions() throws IOException { + Path binDir = Files.createDirectories(tempDir.resolve("bin")); + Files.createFile(binDir.resolve("asprof")); + + Map flags = new HashMap<>(); + flags.put("thread", new String[] { "" }); + flags.put("simple", new String[] { "" }); + flags.put("reverse", new String[] { "" }); + + ProfileServlet.ProfileRequest req = parseRequest(flags, "event", "alloc", "interval", "500", + "jstackdepth", "128", "bufsize", "50000", "width", "800", "height", "12", "minwidth", "1.0"); + File outputFile = File.createTempFile("prof", ".html"); + outputFile.deleteOnExit(); + + List cmd = ProfilerCommandMapper.toCliCommand(req, outputFile, tempDir.toString(), 99); + assertTrue(cmd.contains("-e")); + assertTrue(cmd.contains("alloc")); + assertTrue(cmd.contains("-i")); + assertTrue(cmd.contains("500")); + assertTrue(cmd.contains("-j")); + assertTrue(cmd.contains("128")); + assertTrue(cmd.contains("-b")); + assertTrue(cmd.contains("50000")); + assertTrue(cmd.contains("-t")); + assertTrue(cmd.contains("-s")); + assertTrue(cmd.contains("--width")); + assertTrue(cmd.contains("800")); + assertTrue(cmd.contains("--height")); + assertTrue(cmd.contains("12")); + assertTrue(cmd.contains("--minwidth")); + assertTrue(cmd.contains("1.0")); + assertTrue(cmd.contains("--reverse")); + } + + // ---- Format mapping ---- + + @Test + public void testOutputFormatMappingAllValues() { + assertEquals("summary", ProfilerCommandMapper.toFormatString(ProfileServlet.Output.SUMMARY)); + assertEquals("traces", ProfilerCommandMapper.toFormatString(ProfileServlet.Output.TRACES)); + assertEquals("flat", ProfilerCommandMapper.toFormatString(ProfileServlet.Output.FLAT)); + assertEquals("collapsed", + ProfilerCommandMapper.toFormatString(ProfileServlet.Output.COLLAPSED)); + assertEquals("tree", ProfilerCommandMapper.toFormatString(ProfileServlet.Output.TREE)); + assertEquals("jfr", ProfilerCommandMapper.toFormatString(ProfileServlet.Output.JFR)); + assertEquals("svg", ProfilerCommandMapper.toFormatString(ProfileServlet.Output.SVG)); + assertEquals("html", ProfilerCommandMapper.toFormatString(ProfileServlet.Output.HTML)); + } + + // ---- helpers ---- + + private ProfileServlet.ProfileRequest parseRequest(Map paramMap, + String... kvPairs) { + ProfileServlet servlet = new ProfileServlet(null); + HttpServletRequest req = Mockito.mock(HttpServletRequest.class); + Mockito.when(req.getParameterMap()).thenReturn(paramMap); + // defaults + String[] keys = { "pid", "duration", "output", "event", "interval", "jstackdepth", "bufsize", + "width", "height", "minwidth", "refreshDelay" }; + for (String k : keys) { + Mockito.when(req.getParameter(k)).thenReturn(null); + } + for (int i = 0; i < kvPairs.length; i += 2) { + Mockito.when(req.getParameter(kvPairs[i])).thenReturn(kvPairs[i + 1]); + } + return servlet.parseProfileRequest(req); + } +} diff --git a/hbase-resource-bundle/src/main/resources/supplemental-models.xml b/hbase-resource-bundle/src/main/resources/supplemental-models.xml index 0ce3c3ebc648..fd049b5b8d88 100644 --- a/hbase-resource-bundle/src/main/resources/supplemental-models.xml +++ b/hbase-resource-bundle/src/main/resources/supplemental-models.xml @@ -55,6 +55,20 @@ under the License. + + + tools.profiler + async-profiler + + + Apache License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + diff --git a/pom.xml b/pom.xml index 92e2fab10f97..6e8e7f8361b3 100644 --- a/pom.xml +++ b/pom.xml @@ -1095,6 +1095,7 @@ 5.32.0 6.29.0 5.23.0 + 4.4