This is an automated email from the ASF dual-hosted git repository. asf-gitbox-commits pushed a commit to branch branch_10_1 in repository https://gitbox.apache.org/repos/asf/solr.git
commit 296b69b32ab72f36d89b9d83297a7877e7d6c785 Author: Yash Goswami <[email protected]> AuthorDate: Sun Sep 27 05:38:29 2026 +0530 SOLR-16458: Add node threads JAX-RS API (#4722) Co-authored-by: Eric Pugh <[email protected]> (cherry picked from commit 72add954641a3ceacb99ecfaa02717c81f22926e) (cherry picked from commit 89b46dc36b164eaa757c0bf6cd24006040e2d583) --- changelog/unreleased/SOLR-16458.yml | 9 ++ .../solr/client/api/endpoint/NodeThreadsApi.java | 33 +++++ .../solr/client/api/model/NodeThreadsResponse.java | 145 ++++++++++++++++++ .../solr/handler/admin/ThreadDumpHandler.java | 165 +++++++-------------- .../solr/handler/admin/api/NodeThreadsAPI.java | 122 ++++++++++++--- .../solr/handler/admin/ThreadDumpHandlerTest.java | 59 ++++++++ .../solr/handler/admin/api/NodeThreadsAPITest.java | 54 +++++++ .../handler/admin/api/V2NodeAPIMappingTest.java | 31 +--- .../pages/implicit-requesthandlers.adoc | 53 ++++++- solr/webapp/web/js/angular/controllers/threads.js | 37 +++-- solr/webapp/web/js/angular/services.js | 14 +- 11 files changed, 530 insertions(+), 192 deletions(-) diff --git a/changelog/unreleased/SOLR-16458.yml b/changelog/unreleased/SOLR-16458.yml new file mode 100644 index 00000000000..a899e68348d --- /dev/null +++ b/changelog/unreleased/SOLR-16458.yml @@ -0,0 +1,9 @@ +title: > + SolrJ now offers a `SystemApi.GetThreadDump` SolrRequest class for fetching + thread dumps from a single Solr node through the JAX-RS `/api/node/threads` API. +type: added +authors: + - name: Yash Goswami +links: + - name: SOLR-16458 + url: https://issues.apache.org/jira/browse/SOLR-16458 diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/NodeThreadsApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/NodeThreadsApi.java new file mode 100644 index 00000000000..39dcb17b588 --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/NodeThreadsApi.java @@ -0,0 +1,33 @@ +/* + * 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.solr.client.api.endpoint; + +import io.swagger.v3.oas.annotations.Operation; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import org.apache.solr.client.api.model.NodeThreadsResponse; + +/** V2 API for retrieving thread information from the receiving node. */ +@Path("/node/threads") +public interface NodeThreadsApi { + + @GET + @Operation( + summary = "Retrieve thread information for the receiving node.", + tags = {"system"}) + NodeThreadsResponse getThreadDump(); +} diff --git a/solr/api/src/java/org/apache/solr/client/api/model/NodeThreadsResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/NodeThreadsResponse.java new file mode 100644 index 00000000000..ff575497b45 --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/NodeThreadsResponse.java @@ -0,0 +1,145 @@ +/* + * 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.solr.client.api.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +/** Response body for the {@code /api/node/threads} endpoint. */ +public class NodeThreadsResponse extends SolrJerseyResponse { + + @Schema(description = "Thread information for the receiving JVM.") + @JsonProperty("system") + public SystemInfo system; + + public static class SystemInfo { + + @Schema(description = "Live and peak platform-thread counts.") + @JsonProperty("threadCount") + public ThreadCount threadCount; + + @Schema(description = "Threads detected in a deadlock. Omitted when no deadlock is detected.") + @JsonProperty("deadlocks") + public List<ThreadEntry> deadlocks; + + @Schema(description = "Snapshot of live platform threads, each wrapped in a thread property.") + @JsonProperty("threadDump") + public List<ThreadEntry> threadDump; + } + + public static class ThreadCount { + + @Schema(description = "Current number of live platform threads.") + @JsonProperty("current") + public long current; + + @Schema( + description = + "Peak live platform-thread count since JVM startup or the last peak-count reset.") + @JsonProperty("peak") + public long peak; + + @Schema(description = "Current number of live daemon platform threads.") + @JsonProperty("daemon") + public long daemon; + } + + public static class ThreadEntry { + + @Schema(description = "Details of one platform thread.") + @JsonProperty("thread") + public ThreadInfo thread; + } + + public static class ThreadInfo { + + @Schema(description = "JVM thread identifier.") + @JsonProperty("id") + public long id; + + @Schema(description = "Thread name.") + @JsonProperty("name") + public String name; + + @Schema(description = "JVM thread state, such as RUNNABLE, BLOCKED, or WAITING.") + @JsonProperty("state") + public String state; + + @Schema(description = "Name of the lock the thread is waiting for, if any.") + @JsonProperty("lock") + public String lock; + + @Schema(description = "Lock the thread is waiting for, if any, and its owner when known.") + @JsonProperty("lock-waiting") + public LockWaiting lockWaiting; + + @Schema(description = "Ownable synchronizers held by this thread. Omitted when none are held.") + @JsonProperty("synchronizers-locked") + public List<String> synchronizersLocked; + + @Schema(description = "Object monitors held by this thread. Omitted when none are held.") + @JsonProperty("monitors-locked") + public List<String> monitorsLocked; + + @Schema(description = "Present and true when the thread is suspended.") + @JsonProperty("suspended") + public Boolean suspended; + + @Schema(description = "Present and true when the thread is executing native code.") + @JsonProperty("native") + public Boolean nativeThread; + + @Schema( + description = + "Total thread CPU time formatted in milliseconds with an ms suffix. Omitted when unsupported; a negative value indicates unavailable timing.") + @JsonProperty("cpuTime") + public String cpuTime; + + @Schema( + description = + "Thread CPU time in user mode formatted in milliseconds with an ms suffix. Omitted when unsupported; a negative value indicates unavailable timing.") + @JsonProperty("userTime") + public String userTime; + + @Schema(description = "Stack frames, starting with the most recent method invocation.") + @JsonProperty("stackTrace") + public List<String> stackTrace; + } + + public static class LockWaiting { + + @Schema(description = "Lock class name and identity hash code.") + @JsonProperty("name") + public String name; + + @Schema(description = "Thread owning the lock. Omitted when no owner is known.") + @JsonProperty("owner") + public LockOwner owner; + } + + public static class LockOwner { + + @Schema(description = "Name of the thread owning the lock.") + @JsonProperty("name") + public String name; + + @Schema(description = "JVM thread identifier.") + @JsonProperty("id") + public long id; + } +} diff --git a/solr/core/src/java/org/apache/solr/handler/admin/ThreadDumpHandler.java b/solr/core/src/java/org/apache/solr/handler/admin/ThreadDumpHandler.java index 5d2832cd74e..9844bdac756 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/ThreadDumpHandler.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/ThreadDumpHandler.java @@ -20,16 +20,12 @@ import static org.apache.solr.common.params.CommonParams.ID; import static org.apache.solr.common.params.CommonParams.NAME; import java.io.IOException; -import java.lang.management.LockInfo; -import java.lang.management.ManagementFactory; -import java.lang.management.ThreadInfo; -import java.lang.management.ThreadMXBean; -import java.util.ArrayList; import java.util.Collection; import java.util.List; -import java.util.Locale; -import org.apache.solr.api.AnnotatedApi; import org.apache.solr.api.Api; +import org.apache.solr.api.JerseyResource; +import org.apache.solr.client.api.model.NodeThreadsResponse.ThreadEntry; +import org.apache.solr.client.api.model.NodeThreadsResponse.ThreadInfo; import org.apache.solr.common.util.NamedList; import org.apache.solr.common.util.SimpleOrderedMap; import org.apache.solr.handler.RequestHandlerBase; @@ -45,124 +41,71 @@ public class ThreadDumpHandler extends RequestHandlerBase { @Override public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throws IOException { - SimpleOrderedMap<Object> system = new SimpleOrderedMap<>(); - rsp.add("system", system); - - ThreadMXBean tmbean = ManagementFactory.getThreadMXBean(); - - // Thread Count - SimpleOrderedMap<Object> nl = new SimpleOrderedMap<>(); - nl.add("current", tmbean.getThreadCount()); - nl.add("peak", tmbean.getPeakThreadCount()); - nl.add("daemon", tmbean.getDaemonThreadCount()); - system.add("threadCount", nl); - - // Deadlocks - ThreadInfo[] tinfos; - long[] tids = tmbean.findDeadlockedThreads(); - if (tids != null) { - tinfos = tmbean.getThreadInfo(tids, Integer.MAX_VALUE); - NamedList<SimpleOrderedMap<Object>> lst = new NamedList<>(); - for (ThreadInfo ti : tinfos) { - if (ti != null) { - lst.add("thread", getThreadInfo(ti, tmbean)); - } - } - system.add("deadlocks", lst); + final var response = new NodeThreadsAPI().getThreadDump(); + final var system = new SimpleOrderedMap<Object>(); + final var counts = new SimpleOrderedMap<Object>(); + counts.add("current", Math.toIntExact(response.system.threadCount.current)); + counts.add("peak", Math.toIntExact(response.system.threadCount.peak)); + counts.add("daemon", Math.toIntExact(response.system.threadCount.daemon)); + system.add("threadCount", counts); + if (response.system.deadlocks != null) { + system.add("deadlocks", toNamedList(response.system.deadlocks)); } - - // Now show all the threads.... - - tinfos = tmbean.dumpAllThreads(true, true); - NamedList<SimpleOrderedMap<Object>> lst = new NamedList<>(); - for (ThreadInfo ti : tinfos) { - if (ti != null) { - lst.add("thread", getThreadInfo(ti, tmbean)); - } - } - system.add("threadDump", lst); + system.add("threadDump", toNamedList(response.system.threadDump)); + rsp.add("system", system); rsp.setHttpCaching(false); } - // -------------------------------------------------------------------------------- - // -------------------------------------------------------------------------------- - - private static SimpleOrderedMap<Object> getThreadInfo(ThreadInfo ti, ThreadMXBean tmbean) { - SimpleOrderedMap<Object> info = new SimpleOrderedMap<>(); - long tid = ti.getThreadId(); - - info.add(ID, tid); - info.add(NAME, ti.getThreadName()); - info.add("state", ti.getThreadState().toString()); + private static NamedList<SimpleOrderedMap<Object>> toNamedList(List<ThreadEntry> threads) { + final var result = new NamedList<SimpleOrderedMap<Object>>(); + for (var entry : threads) { + result.add("thread", toNamedList(entry.thread)); + } + return result; + } - if (ti.getLockName() != null) { - // TODO: this is redundent with lock-waiting below .. deprecate & remove - // TODO: (but first needs UI change) - info.add("lock", ti.getLockName()); + private static SimpleOrderedMap<Object> toNamedList(ThreadInfo thread) { + final var info = new SimpleOrderedMap<Object>(); + info.add(ID, thread.id); + info.add(NAME, thread.name); + info.add("state", thread.state); + if (thread.lock != null) { + info.add("lock", thread.lock); } - { - final LockInfo lockInfo = ti.getLockInfo(); - if (null != lockInfo) { - final SimpleOrderedMap<Object> lock = new SimpleOrderedMap<>(); - info.add("lock-waiting", lock); - lock.add(NAME, lockInfo.toString()); - if (-1 == ti.getLockOwnerId() && null == ti.getLockOwnerName()) { - lock.add("owner", null); - } else { - final SimpleOrderedMap<Object> owner = new SimpleOrderedMap<>(); - lock.add("owner", owner); - owner.add(NAME, ti.getLockOwnerName()); - owner.add(ID, ti.getLockOwnerId()); - } + if (thread.lockWaiting != null) { + final var lock = new SimpleOrderedMap<Object>(); + lock.add(NAME, thread.lockWaiting.name); + SimpleOrderedMap<Object> owner = null; + if (thread.lockWaiting.owner != null) { + owner = new SimpleOrderedMap<>(); + owner.add(NAME, thread.lockWaiting.owner.name); + owner.add(ID, thread.lockWaiting.owner.id); } + lock.add("owner", owner); + info.add("lock-waiting", lock); } - { - final LockInfo[] synchronizers = ti.getLockedSynchronizers(); - if (0 < synchronizers.length) { - final List<String> locks = new ArrayList<>(synchronizers.length); - info.add("synchronizers-locked", locks); - for (LockInfo sync : synchronizers) { - locks.add(sync.toString()); - } - } + if (thread.synchronizersLocked != null) { + info.add("synchronizers-locked", thread.synchronizersLocked); } - { - final LockInfo[] monitors = ti.getLockedMonitors(); - if (0 < monitors.length) { - final List<String> locks = new ArrayList<>(monitors.length); - info.add("monitors-locked", locks); - for (LockInfo monitor : monitors) { - locks.add(monitor.toString()); - } - } + if (thread.monitorsLocked != null) { + info.add("monitors-locked", thread.monitorsLocked); } - - if (ti.isSuspended()) { - info.add("suspended", true); + if (thread.suspended != null) { + info.add("suspended", thread.suspended); } - if (ti.isInNative()) { - info.add("native", true); + if (thread.nativeThread != null) { + info.add("native", thread.nativeThread); } - - if (tmbean.isThreadCpuTimeSupported()) { - info.add("cpuTime", formatNanos(tmbean.getThreadCpuTime(tid))); - info.add("userTime", formatNanos(tmbean.getThreadUserTime(tid))); + if (thread.cpuTime != null) { + info.add("cpuTime", thread.cpuTime); } - - // Add the stack trace - int i = 0; - String[] trace = new String[ti.getStackTrace().length]; - for (StackTraceElement ste : ti.getStackTrace()) { - trace[i++] = ste.toString(); + if (thread.userTime != null) { + info.add("userTime", thread.userTime); } - info.add("stackTrace", trace); + info.add("stackTrace", thread.stackTrace.toArray(String[]::new)); return info; } - private static String formatNanos(long ns) { - return String.format(Locale.ROOT, "%.4fms", ns / (double) 1000000); - } - //////////////////////// SolrInfoMBeans methods ////////////////////// @Override @@ -177,12 +120,12 @@ public class ThreadDumpHandler extends RequestHandlerBase { @Override public Collection<Api> getApis() { - return AnnotatedApi.getApis(new NodeThreadsAPI(this)); + return List.of(); } @Override - public Boolean registerV2() { - return Boolean.TRUE; + public Collection<Class<? extends JerseyResource>> getJerseyResources() { + return List.of(NodeThreadsAPI.class); } @Override diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/NodeThreadsAPI.java b/solr/core/src/java/org/apache/solr/handler/admin/api/NodeThreadsAPI.java index bea1827304c..0223010b87e 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/api/NodeThreadsAPI.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/api/NodeThreadsAPI.java @@ -14,34 +14,114 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.apache.solr.handler.admin.api; -import static org.apache.solr.client.solrj.SolrRequest.METHOD.GET; import static org.apache.solr.security.PermissionNameProvider.Name.METRICS_READ_PERM; -import org.apache.solr.api.EndPoint; -import org.apache.solr.handler.admin.ThreadDumpHandler; -import org.apache.solr.request.SolrQueryRequest; -import org.apache.solr.response.SolrQueryResponse; +import jakarta.inject.Inject; +import java.lang.management.LockInfo; +import java.lang.management.ManagementFactory; +import java.lang.management.ThreadInfo; +import java.lang.management.ThreadMXBean; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import org.apache.solr.api.JerseyResource; +import org.apache.solr.client.api.endpoint.NodeThreadsApi; +import org.apache.solr.client.api.model.NodeThreadsResponse; +import org.apache.solr.client.api.model.NodeThreadsResponse.LockOwner; +import org.apache.solr.client.api.model.NodeThreadsResponse.LockWaiting; +import org.apache.solr.client.api.model.NodeThreadsResponse.SystemInfo; +import org.apache.solr.client.api.model.NodeThreadsResponse.ThreadCount; +import org.apache.solr.client.api.model.NodeThreadsResponse.ThreadEntry; +import org.apache.solr.jersey.PermissionName; -/** - * V2 API for triggering a thread dump on the receiving node. - * - * <p>This API (GET /v2/node/threads) is analogous to the v1 /admin/info/threads. - */ -public class NodeThreadsAPI { - private final ThreadDumpHandler handler; +/** Implementation of {@link NodeThreadsApi}. */ +public class NodeThreadsAPI extends JerseyResource implements NodeThreadsApi { + + @Inject + public NodeThreadsAPI() {} + + @Override + @PermissionName(METRICS_READ_PERM) + public NodeThreadsResponse getThreadDump() { + final var response = instantiateJerseyResponse(NodeThreadsResponse.class); + final var tmbean = ManagementFactory.getThreadMXBean(); + response.system = new SystemInfo(); + final var counts = new ThreadCount(); + counts.current = tmbean.getThreadCount(); + counts.peak = tmbean.getPeakThreadCount(); + counts.daemon = tmbean.getDaemonThreadCount(); + response.system.threadCount = counts; + final long[] deadlockedThreads = tmbean.findDeadlockedThreads(); + if (deadlockedThreads != null) { + response.system.deadlocks = + toThreadEntries(tmbean.getThreadInfo(deadlockedThreads, Integer.MAX_VALUE), tmbean); + } + response.system.threadDump = toThreadEntries(tmbean.dumpAllThreads(true, true), tmbean); + return response; + } + + private static List<ThreadEntry> toThreadEntries(ThreadInfo[] threads, ThreadMXBean tmbean) { + final List<ThreadEntry> entries = new ArrayList<>(threads.length); + for (var thread : threads) { + if (thread != null) { + final var entry = new ThreadEntry(); + entry.thread = getThreadInfo(thread, tmbean); + entries.add(entry); + } + } + return entries; + } + + private static NodeThreadsResponse.ThreadInfo getThreadInfo(ThreadInfo ti, ThreadMXBean tmbean) { + final var info = new NodeThreadsResponse.ThreadInfo(); + final long tid = ti.getThreadId(); + info.id = tid; + info.name = ti.getThreadName(); + info.state = ti.getThreadState().toString(); + info.lock = ti.getLockName(); + final LockInfo lockInfo = ti.getLockInfo(); + if (lockInfo != null) { + info.lockWaiting = new LockWaiting(); + info.lockWaiting.name = lockInfo.toString(); + if (ti.getLockOwnerId() != -1 || ti.getLockOwnerName() != null) { + info.lockWaiting.owner = new LockOwner(); + info.lockWaiting.owner.name = ti.getLockOwnerName(); + info.lockWaiting.owner.id = ti.getLockOwnerId(); + } + } + info.synchronizersLocked = toLockNames(ti.getLockedSynchronizers()); + info.monitorsLocked = toLockNames(ti.getLockedMonitors()); + if (ti.isSuspended()) { + info.suspended = true; + } + if (ti.isInNative()) { + info.nativeThread = true; + } + if (tmbean.isThreadCpuTimeSupported()) { + info.cpuTime = formatNanos(tmbean.getThreadCpuTime(tid)); + info.userTime = formatNanos(tmbean.getThreadUserTime(tid)); + } + info.stackTrace = new ArrayList<>(ti.getStackTrace().length); + for (StackTraceElement ste : ti.getStackTrace()) { + info.stackTrace.add(ste.toString()); + } + return info; + } - public NodeThreadsAPI(ThreadDumpHandler handler) { - this.handler = handler; + private static List<String> toLockNames(LockInfo[] locks) { + if (locks.length == 0) { + return null; + } + final List<String> names = new ArrayList<>(locks.length); + for (LockInfo lock : locks) { + names.add(lock.toString()); + } + return names; } - @EndPoint( - path = {"/node/threads"}, - method = GET, - permission = METRICS_READ_PERM) - public void triggerThreadDump(SolrQueryRequest req, SolrQueryResponse rsp) throws Exception { - handler.handleRequestBody(req, rsp); + private static String formatNanos(long ns) { + return String.format(Locale.ROOT, "%.4fms", ns / (double) 1000000); } } diff --git a/solr/core/src/test/org/apache/solr/handler/admin/ThreadDumpHandlerTest.java b/solr/core/src/test/org/apache/solr/handler/admin/ThreadDumpHandlerTest.java index 14ee85e3a3d..fb9ab708133 100644 --- a/solr/core/src/test/org/apache/solr/handler/admin/ThreadDumpHandlerTest.java +++ b/solr/core/src/test/org/apache/solr/handler/admin/ThreadDumpHandlerTest.java @@ -33,6 +33,10 @@ import org.apache.solr.client.solrj.embedded.EmbeddedSolrServer; import org.apache.solr.client.solrj.request.GenericSolrRequest; import org.apache.solr.common.params.ModifiableSolrParams; import org.apache.solr.common.util.NamedList; +import org.apache.solr.common.util.SimpleOrderedMap; +import org.apache.solr.handler.admin.api.NodeThreadsAPI; +import org.apache.solr.jersey.SolrJacksonMapper; +import org.apache.solr.response.SolrQueryResponse; import org.junit.BeforeClass; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -51,6 +55,61 @@ public class ThreadDumpHandlerTest extends SolrTestCaseJ4 { initCore("solrconfig.xml", "schema.xml"); } + public void testV1ResponseStructure() throws Exception { + final var response = new SolrQueryResponse(); + try (var handler = new ThreadDumpHandler()) { + handler.handleRequestBody(null, response); + } + assertFalse(response.isHttpCaching()); + assertTrue(response.getValues().get("system") instanceof SimpleOrderedMap<?>); + final var system = (NamedList<?>) response.getValues().get("system"); + assertTrue(system.get("threadCount") instanceof SimpleOrderedMap<?>); + final var counts = (NamedList<?>) system.get("threadCount"); + assertTrue(counts.get("current") instanceof Integer); + assertTrue(counts.get("peak") instanceof Integer); + assertTrue(counts.get("daemon") instanceof Integer); + final var threads = (NamedList<?>) system.get("threadDump"); + assertTrue(threads.size() > 1); + for (var entry : threads) { + assertEquals("thread", entry.getKey()); + assertTrue(entry.getValue() instanceof SimpleOrderedMap<?>); + final var thread = (NamedList<?>) entry.getValue(); + assertTrue(thread.get("id") instanceof Long); + assertTrue(thread.get("stackTrace") instanceof String[]); + if (thread.get("lock-waiting") != null) { + assertTrue(thread.get("lock-waiting") instanceof SimpleOrderedMap<?>); + final var lock = (NamedList<?>) thread.get("lock-waiting"); + if (lock.get("owner") != null) { + assertTrue(lock.get("owner") instanceof SimpleOrderedMap<?>); + } + } + } + } + + public void testV2ResponseStructure() throws Exception { + final var response = new NodeThreadsAPI().getThreadDump(); + assertTrue(response.system.threadCount.current > 0); + assertTrue(response.system.threadDump.size() > 1); + final var currentThread = + response.system.threadDump.stream() + .map(entry -> entry.thread) + .filter(thread -> thread.id == Thread.currentThread().threadId()) + .findFirst() + .orElseThrow(); + assertEquals(Thread.currentThread().getName(), currentThread.name); + assertEquals("RUNNABLE", currentThread.state); + assertFalse(currentThread.stackTrace.isEmpty()); + final var mapper = SolrJacksonMapper.getObjectMapper(); + final var json = mapper.readTree(mapper.writeValueAsString(response)); + final var threads = json.path("system").path("threadDump"); + assertTrue(threads.isArray()); + assertEquals(response.system.threadDump.size(), threads.size()); + for (var entry : threads) { + assertTrue(entry.path("thread").isObject()); + assertTrue(entry.path("thread").path("stackTrace").isArray()); + } + } + public void testMonitor() throws Exception { doTestMonitor(false); } diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/NodeThreadsAPITest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeThreadsAPITest.java new file mode 100644 index 00000000000..8663928c9e3 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/NodeThreadsAPITest.java @@ -0,0 +1,54 @@ +/* + * 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.solr.handler.admin.api; + +import org.apache.solr.SolrTestCase; +import org.apache.solr.client.solrj.request.SystemApi; +import org.apache.solr.util.SolrJettyTestRule; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; + +/** HTTP coverage for the node thread dump through the generated SolrJ client. */ +public class NodeThreadsAPITest extends SolrTestCase { + + @ClassRule public static final SolrJettyTestRule solrTestRule = new SolrJettyTestRule(); + + @BeforeClass + public static void setupSolr() throws Exception { + solrTestRule.startSolr(createTempDir()); + } + + @Test + public void testThreadDump() throws Exception { + final var response = new SystemApi.GetThreadDump().process(solrTestRule.getAdminClient()); + + assertNotNull(response.responseHeader); + assertEquals(0, response.responseHeader.status); + assertNull(response.error); + assertTrue(response.system.threadCount.current > 0); + assertFalse(response.system.threadDump.isEmpty()); + final var currentThread = + response.system.threadDump.stream() + .map(entry -> entry.thread) + .filter(thread -> thread.id == Thread.currentThread().threadId()) + .findFirst() + .orElseThrow(); + assertEquals(Thread.currentThread().getName(), currentThread.name); + assertFalse(currentThread.stackTrace.isEmpty()); + } +} diff --git a/solr/core/src/test/org/apache/solr/handler/admin/api/V2NodeAPIMappingTest.java b/solr/core/src/test/org/apache/solr/handler/admin/api/V2NodeAPIMappingTest.java index ac2b660bc88..0d08741bab0 100644 --- a/solr/core/src/test/org/apache/solr/handler/admin/api/V2NodeAPIMappingTest.java +++ b/solr/core/src/test/org/apache/solr/handler/admin/api/V2NodeAPIMappingTest.java @@ -21,7 +21,6 @@ import static org.apache.solr.common.params.CommonParams.ACTION; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; import java.util.HashMap; import java.util.List; @@ -34,8 +33,6 @@ import org.apache.solr.common.util.CommandOperation; import org.apache.solr.common.util.ContentStreamBase; import org.apache.solr.handler.RequestHandlerBase; import org.apache.solr.handler.admin.CoreAdminHandler; -import org.apache.solr.handler.admin.InfoHandler; -import org.apache.solr.handler.admin.ThreadDumpHandler; import org.apache.solr.request.SolrQueryRequest; import org.apache.solr.request.SolrQueryRequestBase; import org.apache.solr.response.SolrQueryResponse; @@ -49,8 +46,6 @@ public class V2NodeAPIMappingTest extends SolrTestCaseJ4 { private ApiBag apiBag; private ArgumentCaptor<SolrQueryRequest> queryRequestCaptor; private CoreAdminHandler mockCoresHandler; - private InfoHandler infoHandler; - private ThreadDumpHandler mockThreadDumpHandler; @BeforeClass public static void ensureWorkingMockito() { @@ -60,14 +55,10 @@ public class V2NodeAPIMappingTest extends SolrTestCaseJ4 { @Before public void setupApiBag() { mockCoresHandler = mock(CoreAdminHandler.class); - infoHandler = mock(InfoHandler.class); - mockThreadDumpHandler = mock(ThreadDumpHandler.class); queryRequestCaptor = ArgumentCaptor.forClass(SolrQueryRequest.class); - when(infoHandler.getThreadDumpHandler()).thenReturn(mockThreadDumpHandler); - apiBag = new ApiBag(false); - registerAllNodeApis(apiBag, mockCoresHandler, infoHandler); + registerAllNodeApis(apiBag, mockCoresHandler); } @Test @@ -108,28 +99,12 @@ public class V2NodeAPIMappingTest extends SolrTestCaseJ4 { assertEquals("true", v1Params.get("rejoinAtHead")); } - @Test - public void testThreadDumpApiAllProperties() throws Exception { - final ModifiableSolrParams solrParams = new ModifiableSolrParams(); - solrParams.add("anyParamName", "anyParamValue"); - final SolrParams v1Params = - captureConvertedThreadDumpV1Params("/node/threads", "GET", solrParams); - - // All parameters are passed through to v1 API as-is - assertEquals("anyParamValue", v1Params.get("anyParamName")); - } - private SolrParams captureConvertedCoreV1Params(String path, String method, String v2RequestBody) throws Exception { return doCaptureParams( path, method, new ModifiableSolrParams(), v2RequestBody, mockCoresHandler); } - private SolrParams captureConvertedThreadDumpV1Params( - String path, String method, SolrParams inputParams) throws Exception { - return doCaptureParams(path, method, inputParams, null, mockThreadDumpHandler); - } - private SolrParams doCaptureParams( String path, String method, @@ -161,10 +136,8 @@ public class V2NodeAPIMappingTest extends SolrTestCaseJ4 { return queryRequestCaptor.getValue().getParams(); } - private static void registerAllNodeApis( - ApiBag apiBag, CoreAdminHandler coreHandler, InfoHandler infoHandler) { + private static void registerAllNodeApis(ApiBag apiBag, CoreAdminHandler coreHandler) { apiBag.registerObject(new OverseerOperationAPI(coreHandler)); apiBag.registerObject(new RejoinLeaderElectionAPI(coreHandler)); - apiBag.registerObject(new NodeThreadsAPI(infoHandler.getThreadDumpHandler())); } } diff --git a/solr/solr-ref-guide/modules/configuration-guide/pages/implicit-requesthandlers.adoc b/solr/solr-ref-guide/modules/configuration-guide/pages/implicit-requesthandlers.adoc index 6adff4771b7..c99fb342feb 100644 --- a/solr/solr-ref-guide/modules/configuration-guide/pages/implicit-requesthandlers.adoc +++ b/solr/solr-ref-guide/modules/configuration-guide/pages/implicit-requesthandlers.adoc @@ -130,12 +130,55 @@ Core Info:: Return core information. Threads:: Return info on all JVM threads. + -[cols="3*.",frame=none,grid=cols,options="header"] -|=== -|API Endpoints |Class & Javadocs |Paramset -|v1: `solr/admin/info/threads` +[tabs#thread-dump-api] +====== +V1 API:: ++ +==== +`solr/admin/info/threads` + +Abbreviated JSON response with the default `json.nl=flat` format. +The thread dump alternates `"thread"` keys and thread objects: + +[source,json] +---- +{ + "system": { + "threadDump": [ + "thread", {"id": 1, "name": "main", "state": "RUNNABLE"}, + "thread", {"id": 2, "name": "worker", "state": "WAITING"} + ] + } +} +---- +==== -v2: `api/node/threads` |{solr-javadocs}/core/org/apache/solr/handler/admin/ThreadDumpHandler.html[ThreadDumpHandler] |`_ADMIN_THREADS` +V2 API:: ++ +==== +`api/node/threads` + +Abbreviated JSON response. +The thread dump is an array of objects, each containing a `"thread"` property: + +[source,json] +---- +{ + "system": { + "threadDump": [ + {"thread": {"id": 1, "name": "main", "state": "RUNNABLE"}}, + {"thread": {"id": 2, "name": "worker", "state": "WAITING"}} + ] + } +} +---- +==== +====== ++ +[cols="2*.",frame=none,grid=cols,options="header"] +|=== +|Class & Javadocs |Paramset +|{solr-javadocs}/core/org/apache/solr/handler/admin/ThreadDumpHandler.html[ThreadDumpHandler] |`_ADMIN_THREADS` |=== === Analysis Handlers diff --git a/solr/webapp/web/js/angular/controllers/threads.js b/solr/webapp/web/js/angular/controllers/threads.js index 22a90029593..123a42d6382 100644 --- a/solr/webapp/web/js/angular/controllers/threads.js +++ b/solr/webapp/web/js/angular/controllers/threads.js @@ -16,25 +16,32 @@ */ solrAdminApp.controller('ThreadsController', - function($scope, Threads, Constants){ + function($scope, $timeout, SystemV2, Constants, ApiErrorHandler){ $scope.resetMenu("threads", Constants.IS_ROOT_PAGE); $scope.refresh = function() { - Threads.get(function(data) { - var threadDump = data.system.threadDump; - var threads = []; - for (var i=1; i<threadDump.length; i+=2) { - var thread = threadDump[i]; - if (!!thread.stackTrace) { - var stackTrace = []; - for (var j=0; j<thread.stackTrace.length; j++) { - var trace = thread.stackTrace[j].replace("(", "\u200B("); // allow wrapping to happen, \u200B is a zero-width space - stackTrace.push({id:thread.id + ":" + j, trace: trace}); + SystemV2.getThreadDump(function(error, data, response) { + $timeout(function() { + if (error) { ApiErrorHandler.handle(response); return; } + if (!data || !data.system) { + $scope.threads = []; + return; + } + var threadDump = data.system.threadDump; + var threads = []; + for (var i=0; i<threadDump.length; i++) { + var thread = threadDump[i].thread; + if (!!thread.stackTrace) { + var stackTrace = []; + for (var j=0; j<thread.stackTrace.length; j++) { + var trace = thread.stackTrace[j].replace("(", "\u200B("); // allow wrapping to happen, \u200B is a zero-width space + stackTrace.push({id:thread.id + ":" + j, trace: trace}); + } + thread.stackTrace = stackTrace; } - thread.stackTrace = stackTrace; + threads.push(thread); } - threads.push(thread); - } - $scope.threads = threads; + $scope.threads = threads; + }); }); }; $scope.toggleStacktrace = function(thread) { diff --git a/solr/webapp/web/js/angular/services.js b/solr/webapp/web/js/angular/services.js index 40768805c2f..e6fa6b37cfa 100644 --- a/solr/webapp/web/js/angular/services.js +++ b/solr/webapp/web/js/angular/services.js @@ -165,7 +165,7 @@ solrAdminServices.factory('Metrics', // v2 ClusterAPI (/api/cluster) delegates straight through to the same v1 CollectionsHandler // that v1's CLUSTERSTATUS action used, so the response shape is byte-identical -- no // generated solrApi client class exists for it (old-style @EndPoint API, predates the - // OpenAPI-based v2 framework), so this stays a plain $resource, like Threads/ParamSet. + // OpenAPI-based v2 framework), so this stays a plain $resource, like ParamSet. return $resource('/api/cluster', {'wt':'json', '_':Date.now()}, { "status": {} }); @@ -244,14 +244,6 @@ solrAdminServices.factory('Metrics', delete solrApi.ApiClient.instance.defaultHeaders["User-Agent"]; return new solrApi.NodeApi(); }) -.factory('Threads', - ['$resource', function($resource) { - // v2 NodeThreadsAPI (/api/node/threads) still just delegates straight through to the same v1 - // ThreadDumpHandler, so the response shape is byte-identical -- no generated solrApi client - // class exists for it (it predates the OpenAPI-based v2 API framework), so this stays a plain - // $resource, like Security and (partially) SchemaDesigner. - return $resource('/api/node/threads', {'wt':'json', '_':Date.now()}); - }]) .factory('Replication', ['$resource', function($resource) { return $resource(':core/replication', {'wt':'json', core: "@core", '_':Date.now()}, { @@ -278,7 +270,7 @@ solrAdminServices.factory('Metrics', // v2 GetConfigAPI/ModifyParamSetAPI (/api/(cores|collections)/:core/config/params) still // delegate straight through to the same v1 SolrConfigHandler, so the response shape is // byte-identical -- no generated solrApi client class exists for it (old-style @EndPoint API, - // predates the OpenAPI-based v2 framework), so this stays a plain $resource, like Threads. + // predates the OpenAPI-based v2 framework), so this stays a plain $resource, like Collections. // NB: unlike v1's flexible routing, the v2 API requires knowing up front whether ":core" is a // collection name (SolrCloud) or an actual core name (standalone/user-managed) -- // /api/collections/... 500s in standalone mode (it tries to resolve aliases, which needs ZK), @@ -403,7 +395,7 @@ solrAdminServices.factory('Metrics', // body (the server deliberately reads the raw content stream, dispatched by Content-Type, // rather than a formal parameter) and query() takes no query params at all (the server // forwards arbitrary SolrParams straight through). Both stay on this plain $resource, like - // Threads/Collections/ParamSet. Every other Schema Designer endpoint uses SchemaDesignerV2. + // Collections/ParamSet. Every other Schema Designer endpoint uses SchemaDesignerV2. return $resource('/api/schema-designer/:configSet/:path', {wt: 'json', path: '@path', configSet: '@configSet', filePath: '@filePath', _:Date.now()}, { get: {method: "GET"}, post: {method: "POST", timeout: 90000},
