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