diff --git a/core/src/main/java/io/micronaut/core/util/NativeImageUtils.java b/core/src/main/java/io/micronaut/core/util/NativeImageUtils.java new file mode 100644 index 0000000000..9c5f98f191 --- /dev/null +++ b/core/src/main/java/io/micronaut/core/util/NativeImageUtils.java @@ -0,0 +1,83 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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 io.micronaut.core.util; + +import io.micronaut.core.annotation.Internal; + +/** + * Utility class to retrieve information about the context in which code gets executed. + * Partial fork of {@code org.graalvm.nativeimage.ImageInfo} to avoid a dependency on {@code org.graalvm.sdk:nativeimage}. + * + * @since 4.8.0 + */ +@Internal +public final class NativeImageUtils { + /** + * Holds the string that is the name of the system property providing information about the + * context in which code is currently executing. If the property returns the string given by + * {@link #PROPERTY_IMAGE_CODE_VALUE_BUILDTIME} the code is executing in the context of image + * building (e.g. in a static initializer of a class that will be contained in the image). If + * the property returns the string given by {@link #PROPERTY_IMAGE_CODE_VALUE_RUNTIME} the code + * is executing at image runtime. Otherwise, the property is not set. + */ + public static final String PROPERTY_IMAGE_CODE_KEY = "org.graalvm.nativeimage.imagecode"; + + /** + * Holds the string that will be returned by the system property for + * {@link NativeImageUtils#PROPERTY_IMAGE_CODE_KEY} if code is executing in the context of image + * building (e.g. in a static initializer of class that will be contained in the image). + */ + public static final String PROPERTY_IMAGE_CODE_VALUE_BUILDTIME = "buildtime"; + + /** + * Holds the string that will be returned by the system property for + * {@link NativeImageUtils#PROPERTY_IMAGE_CODE_KEY} if code is executing at image runtime. + */ + public static final String PROPERTY_IMAGE_CODE_VALUE_RUNTIME = "runtime"; + + private NativeImageUtils() { + } + + /** + * Returns true if (at the time of the call) code is executing in the context of image building + * or during image runtime, else false. This method will be const-folded so that it can be used + * to hide parts of an application that only work when running on the JVM. For example: + * {@code if (!ImageInfo.inImageCode()) { ... JVM specific code here ... }} + * @return true if (at the time of the call) code is executing in the context of image building or during image runtime, else false + */ + public static boolean inImageCode() { + return inImageBuildtimeCode() || inImageRuntimeCode(); + } + + /** + * Returns true if (at the time of the call) code is executing at image runtime. This method + * will be const-folded. It can be used to hide parts of an application that only work when + * running as native image. + * @return true if (at the time of the call) code is executing at image runtime. + */ + public static boolean inImageRuntimeCode() { + return PROPERTY_IMAGE_CODE_VALUE_RUNTIME.equals(System.getProperty(PROPERTY_IMAGE_CODE_KEY)); + } + + /** + * Returns true if (at the time of the call) code is executing in the context of image building + * (e.g. in a static initializer of class that will be contained in the image). + * @return true if (at the time of the call) code is executing in the context of image building + */ + public static boolean inImageBuildtimeCode() { + return PROPERTY_IMAGE_CODE_VALUE_BUILDTIME.equals(System.getProperty(PROPERTY_IMAGE_CODE_KEY)); + } +} diff --git a/core/src/test/java/io/micronaut/core/util/NativeImageUtilsInNativeImageTest.java b/core/src/test/java/io/micronaut/core/util/NativeImageUtilsInNativeImageTest.java new file mode 100644 index 0000000000..fc07b1fba6 --- /dev/null +++ b/core/src/test/java/io/micronaut/core/util/NativeImageUtilsInNativeImageTest.java @@ -0,0 +1,17 @@ +package io.micronaut.core.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledInNativeImage; +import org.junit.jupiter.api.condition.EnabledInNativeImage; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class NativeImageUtilsInNativeImageTest { + + @EnabledInNativeImage + @Test + void testInImageCode() { + assertTrue(NativeImageUtils.inImageCode()); + } +} \ No newline at end of file diff --git a/core/src/test/java/io/micronaut/core/util/NativeImageUtilsTest.java b/core/src/test/java/io/micronaut/core/util/NativeImageUtilsTest.java new file mode 100644 index 0000000000..acfcdfee50 --- /dev/null +++ b/core/src/test/java/io/micronaut/core/util/NativeImageUtilsTest.java @@ -0,0 +1,17 @@ +package io.micronaut.core.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledInNativeImage; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +class NativeImageUtilsTest { + + @DisabledInNativeImage + @Test + void testInImageCode() { + assertFalse(NativeImageUtils.inImageCode()); + assertFalse(NativeImageUtils.inImageRuntimeCode()); + assertFalse(NativeImageUtils.inImageBuildtimeCode()); + } +} \ No newline at end of file diff --git a/inject/src/main/java/io/micronaut/context/condition/NotInNativeImage.java b/inject/src/main/java/io/micronaut/context/condition/NotInNativeImage.java new file mode 100644 index 0000000000..e0d4807cf2 --- /dev/null +++ b/inject/src/main/java/io/micronaut/context/condition/NotInNativeImage.java @@ -0,0 +1,31 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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 io.micronaut.context.condition; + +import io.micronaut.core.util.NativeImageUtils; + +/** + * Condition to hide parts of an application that only work when running on the JVM. + * Internal implementation is identical to {@code if (!ImageInfo.inImageCode()). + * @author Sergio del Amo + * @since 4.8.0 + */ +public class NotInNativeImage implements Condition { + @Override + public boolean matches(ConditionContext context) { + return !NativeImageUtils.inImageCode(); + } +} diff --git a/management/build.gradle.kts b/management/build.gradle.kts index bf8d32778f..de7a28b082 100644 --- a/management/build.gradle.kts +++ b/management/build.gradle.kts @@ -4,6 +4,7 @@ plugins { dependencies { annotationProcessor(projects.micronautInjectJava) + testAnnotationProcessor(projects.micronautInjectJava) annotationProcessor(projects.micronautGraal) api(projects.micronautRouter) @@ -19,7 +20,6 @@ dependencies { exclude(group = "io.micronaut") } compileOnly(projects.micronautHttpClientCore) - implementation(libs.managed.reactor) testImplementation(projects.micronautHttpClient) @@ -38,5 +38,5 @@ dependencies { compileOnly(libs.logback.classic) compileOnly(libs.log4j) - + testImplementation(libs.awaitility) } diff --git a/management/src/main/java/io/micronaut/management/health/indicator/threads/DeadlockedThreadsHealthIndicator.java b/management/src/main/java/io/micronaut/management/health/indicator/threads/DeadlockedThreadsHealthIndicator.java new file mode 100644 index 0000000000..592a1cddac --- /dev/null +++ b/management/src/main/java/io/micronaut/management/health/indicator/threads/DeadlockedThreadsHealthIndicator.java @@ -0,0 +1,139 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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 io.micronaut.management.health.indicator.threads; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.context.condition.NotInNativeImage; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.util.StringUtils; +import io.micronaut.health.HealthStatus; +import io.micronaut.management.endpoint.health.HealthEndpoint; +import io.micronaut.management.health.indicator.AbstractHealthIndicator; +import io.micronaut.management.health.indicator.HealthIndicator; +import io.micronaut.management.health.indicator.annotation.Liveness; +import jakarta.inject.Singleton; + +import java.lang.management.ManagementFactory; +import java.lang.management.MonitorInfo; +import java.lang.management.ThreadInfo; +import java.lang.management.ThreadMXBean; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + *

A {@link HealthIndicator} that uses the {@link ThreadMXBean} to check for deadlocked threads. + * Returns {@link HealthStatus#DOWN} if any are found and their {@link ThreadInfo} in the details.

+ * + * @author Andreas Brenk + * @since 4.8.0 + */ +@Singleton +@Liveness +@Requires(condition = NotInNativeImage.class) +@Requires(property = HealthEndpoint.PREFIX + ".deadlocked-threads.enabled", notEquals = StringUtils.FALSE) +@Requires(beans = HealthEndpoint.class) +@Internal +class DeadlockedThreadsHealthIndicator extends AbstractHealthIndicator { + + private static final String NAME = "deadlockedThreads"; + private static final String KEY_THREAD_ID = "threadId"; + private static final String KEY_THREAD_NAME = "threadName"; + private static final String KEY_THREAD_STATE = "threadState"; + private static final String KEY_DAEMON = "daemon"; + private static final String KEY_PRIORITY = "priority"; + private static final String KEY_SUSPENDED = "suspended"; + private static final String KEY_IN_NATIVE = "inNative"; + private static final String KEY_LOCK_NAME = "lockName"; + private static final String KEY_LOCK_OWNER_NAME = "lockOwnerName"; + private static final String KEY_LOCK_OWNER_ID = "lockOwnerId"; + private static final String KEY_LOCKED_SYNCHRONIZERS = "lockedSynchronizers"; + private static final String KEY_STACK_TRACE = "stackTrace"; + + @Override + protected Object getHealthInformation() { + ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean(); + long[] deadlockedThreads = threadMXBean.findDeadlockedThreads(); + if (deadlockedThreads == null) { + this.healthStatus = HealthStatus.UP; + return null; + } + this.healthStatus = HealthStatus.DOWN; + return Arrays.stream(threadMXBean.getThreadInfo(deadlockedThreads, true, true, Integer.MAX_VALUE)) + .map(DeadlockedThreadsHealthIndicator::getDetails) + .toList(); + } + + @Override + public String getName() { + return NAME; + } + + private static Map getDetails(ThreadInfo threadInfo) { + Map details = new LinkedHashMap<>(); + details.put(KEY_THREAD_ID, String.valueOf(threadInfo.getThreadId())); + details.put(KEY_THREAD_NAME, threadInfo.getThreadName()); + details.put(KEY_THREAD_STATE, threadInfo.getThreadState().name()); + details.put(KEY_DAEMON, String.valueOf(threadInfo.isDaemon())); + details.put(KEY_PRIORITY, String.valueOf(threadInfo.getPriority())); + details.put(KEY_SUSPENDED, String.valueOf(threadInfo.isSuspended())); + details.put(KEY_IN_NATIVE, String.valueOf(threadInfo.isInNative())); + details.put(KEY_LOCK_NAME, threadInfo.getLockName()); + details.put(KEY_LOCK_OWNER_NAME, threadInfo.getLockOwnerName()); + details.put(KEY_LOCK_OWNER_ID, String.valueOf(threadInfo.getLockOwnerId())); + details.put(KEY_LOCKED_SYNCHRONIZERS, Arrays.stream(threadInfo.getLockedSynchronizers()).map(String::valueOf).toList()); + details.put(KEY_STACK_TRACE, formatStackTrace(threadInfo)); + return details; + } + + private static String formatStackTrace(ThreadInfo threadInfo) { + StringBuilder sb = new StringBuilder(); + + int i = 0; + StackTraceElement[] stackTrace = threadInfo.getStackTrace(); + for (; i < stackTrace.length; i++) { + StackTraceElement ste = stackTrace[i]; + sb.append(ste.toString()); + sb.append('\n'); + + if (i == 0 && threadInfo.getLockInfo() != null) { + switch (threadInfo.getThreadState()) { + case BLOCKED: + sb.append("- blocked on "); + sb.append(threadInfo.getLockInfo()); + sb.append('\n'); + break; + case WAITING, TIMED_WAITING: + sb.append("- waiting on "); + sb.append(threadInfo.getLockInfo()); + sb.append('\n'); + break; + default: + } + } + + for (MonitorInfo mi : threadInfo.getLockedMonitors()) { + if (mi.getLockedStackDepth() == i) { + sb.append("- locked "); + sb.append(mi); + sb.append('\n'); + } + } + } + + return sb.toString(); + } +} diff --git a/management/src/main/java/io/micronaut/management/health/indicator/threads/package-info.java b/management/src/main/java/io/micronaut/management/health/indicator/threads/package-info.java new file mode 100644 index 0000000000..262aecb795 --- /dev/null +++ b/management/src/main/java/io/micronaut/management/health/indicator/threads/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright 2017-2024 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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. + */ +/** + * Thread deadlock indicator. + * + * @author Andreas Brenk + * @since 4.8.0 + */ +package io.micronaut.management.health.indicator.threads; diff --git a/management/src/test/groovy/io/micronaut/management/health/aggregator/HealthAggregatorSpec.groovy b/management/src/test/groovy/io/micronaut/management/health/aggregator/HealthAggregatorSpec.groovy index 64701dedb3..d842891df7 100644 --- a/management/src/test/groovy/io/micronaut/management/health/aggregator/HealthAggregatorSpec.groovy +++ b/management/src/test/groovy/io/micronaut/management/health/aggregator/HealthAggregatorSpec.groovy @@ -43,24 +43,26 @@ class HealthAggregatorSpec extends Specification { assert appender.events.isEmpty() break case Level.DEBUG: - assert appender.events.size() == 7 + assert appender.events.size() == 8 assert appender.events[0] == 'Health result for compositeDiscoveryClient(): status UP' - assert appender.events[1] == 'Health result for diskSpace: status UP' - assert appender.events[2] == 'Health result for jdbc: status UP' - assert appender.events[3] == 'Health result for jdbc:h2:mem:oneDb: status UP' - assert appender.events[4] == 'Health result for liveness: status UP' - assert appender.events[5] == 'Health result for readiness: status UP' - assert appender.events[6] == 'Health result for service: status UP' + assert appender.events[1] == 'Health result for deadlockedThreads: status UP' + assert appender.events[2] == 'Health result for diskSpace: status UP' + assert appender.events[3] == 'Health result for jdbc: status UP' + assert appender.events[4] == 'Health result for jdbc:h2:mem:oneDb: status UP' + assert appender.events[5] == 'Health result for liveness: status UP' + assert appender.events[6] == 'Health result for readiness: status UP' + assert appender.events[7] == 'Health result for service: status UP' break case Level.TRACE: - assert appender.events.size() == 7 + assert appender.events.size() == 8 assert appender.events[0].contains('Health result for compositeDiscoveryClient(): status UP, details {') - assert appender.events[1].contains('Health result for diskSpace: status UP, details {') - assert appender.events[2].contains('Health result for jdbc: status UP, details {') - assert appender.events[3].contains('Health result for jdbc:h2:mem:oneDb: status UP, details {') - assert appender.events[4] == 'Health result for liveness: status UP, details {}' - assert appender.events[5] == 'Health result for readiness: status UP, details {}' - assert appender.events[6] == 'Health result for service: status UP, details {}' + assert appender.events[1] == 'Health result for deadlockedThreads: status UP, details {}' + assert appender.events[2].contains('Health result for diskSpace: status UP, details {') + assert appender.events[3].contains('Health result for jdbc: status UP, details {') + assert appender.events[4].contains('Health result for jdbc:h2:mem:oneDb: status UP, details {') + assert appender.events[5] == 'Health result for liveness: status UP, details {}' + assert appender.events[6] == 'Health result for readiness: status UP, details {}' + assert appender.events[7] == 'Health result for service: status UP, details {}' break } diff --git a/management/src/test/groovy/io/micronaut/management/health/indicator/threads/DeadlockedThreadsHealthIndicatorConfigurationSpec.groovy b/management/src/test/groovy/io/micronaut/management/health/indicator/threads/DeadlockedThreadsHealthIndicatorConfigurationSpec.groovy new file mode 100644 index 0000000000..2967446473 --- /dev/null +++ b/management/src/test/groovy/io/micronaut/management/health/indicator/threads/DeadlockedThreadsHealthIndicatorConfigurationSpec.groovy @@ -0,0 +1,22 @@ +package io.micronaut.management.health.indicator.threads + +import io.micronaut.context.ApplicationContext +import io.micronaut.core.util.StringUtils +import io.micronaut.management.health.indicator.discovery.DiscoveryClientHealthIndicator +import io.micronaut.management.health.indicator.discovery.DiscoveryClientHealthIndicatorConfiguration +import spock.lang.Specification + +class DeadlockedThreadsHealthIndicatorConfigurationSpec extends Specification { + + void "bean of type DeadlockedThreadsHealthIndicator does not exist if you set endpoints.health.deadlocked-threads.enabled=false"() { + given: + Map conf = ['endpoints.health.deadlocked-threads.enabled': StringUtils.FALSE] + ApplicationContext applicationContext = ApplicationContext.run(conf) + + expect: + !applicationContext.containsBean(DeadlockedThreadsHealthIndicator) + + cleanup: + applicationContext.close() + } +} diff --git a/management/src/test/groovy/io/micronaut/management/health/indicator/threads/DeadlockedThreadsHealthIndicatorSpec.groovy b/management/src/test/groovy/io/micronaut/management/health/indicator/threads/DeadlockedThreadsHealthIndicatorSpec.groovy new file mode 100644 index 0000000000..ad3e3e1658 --- /dev/null +++ b/management/src/test/groovy/io/micronaut/management/health/indicator/threads/DeadlockedThreadsHealthIndicatorSpec.groovy @@ -0,0 +1,79 @@ +package io.micronaut.management.health.indicator.threads + +import io.micronaut.context.ApplicationContext +import io.micronaut.health.HealthStatus +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import reactor.core.publisher.Mono +import spock.lang.Specification + +import static java.lang.Thread.sleep + +class DeadlockedThreadsHealthIndicatorSpec extends Specification { + + Logger log = LoggerFactory.getLogger(DeadlockedThreadsHealthIndicatorSpec) + + def "No deadlocked threads so status is UP"() { + given: + ApplicationContext applicationContext = ApplicationContext.run() + Thread thread1 = new Thread() + Thread thread2 = new Thread() + DeadlockedThreadsHealthIndicator healthIndicator = applicationContext.getBean(DeadlockedThreadsHealthIndicator) + when: + thread1.start() + thread2.start() + def result = Mono.from(healthIndicator.getResult()).block() + + then: + HealthStatus.UP == result.status + null == result.details + + cleanup: + applicationContext.close() + } + + def "Deadlocked threads found so status is DOWN"() { + given: + ApplicationContext applicationContext = ApplicationContext.run() + Object lock1 = new Object() + Object lock2 = new Object() + Thread thread1 = new Thread(() -> { + synchronized (lock1) { + log.debug "Thread 1: Holding lock 1" + + sleep 200 + + synchronized (lock2) { + log.debug "Thread 1: Holding lock 1 and lock 2" + } + } + }) + Thread thread2 = new Thread(() -> { + synchronized (lock2) { + log.debug "Thread 2: Holding lock 2" + + sleep 100 + + synchronized (lock1) { + log.debug "Thread 2: Holding lock 2 and lock 1" + } + } + }) + DeadlockedThreadsHealthIndicator healthIndicator = applicationContext.getBean(DeadlockedThreadsHealthIndicator) + + when: + thread1.start() + thread2.start() + + Thread.sleep(300) + + def result = Mono.from(healthIndicator.getResult()).block() + + then: + HealthStatus.DOWN == result.status + null != result.details + + cleanup: + applicationContext.close() + } +} diff --git a/management/src/test/groovy/io/micronaut/management/health/monitor/HealthMonitorTaskSpec.groovy b/management/src/test/groovy/io/micronaut/management/health/monitor/HealthMonitorTaskSpec.groovy index 43af8aad63..bebe663ff2 100644 --- a/management/src/test/groovy/io/micronaut/management/health/monitor/HealthMonitorTaskSpec.groovy +++ b/management/src/test/groovy/io/micronaut/management/health/monitor/HealthMonitorTaskSpec.groovy @@ -6,6 +6,7 @@ import ch.qos.logback.classic.spi.ILoggingEvent import ch.qos.logback.core.AppenderBase import io.micronaut.context.ApplicationContext import io.micronaut.context.annotation.Requires +import io.micronaut.core.util.StringUtils import io.micronaut.runtime.server.EmbeddedServer import org.slf4j.LoggerFactory import spock.lang.Specification @@ -29,7 +30,8 @@ class HealthMonitorTaskSpec extends Specification { 'spec.name' : 'HealthMonitorTask', 'micronaut.application.name': 'foo', 'micronaut.health.monitor.enabled': true, - 'endpoints.health.sensitive': false + 'endpoints.health.sensitive': false, + 'endpoints.health.deadlocked-threads.enabled': StringUtils.FALSE ]) PollingConditions conditions = new PollingConditions(timeout: 7) diff --git a/management/src/test/java/io/micronaut/management/health/indicator/threads/DeadlockedThreadsHealthIndicatorTest.java b/management/src/test/java/io/micronaut/management/health/indicator/threads/DeadlockedThreadsHealthIndicatorTest.java new file mode 100644 index 0000000000..697ba806e7 --- /dev/null +++ b/management/src/test/java/io/micronaut/management/health/indicator/threads/DeadlockedThreadsHealthIndicatorTest.java @@ -0,0 +1,125 @@ +package io.micronaut.management.health.indicator.threads; + +import io.micronaut.context.ApplicationContext; +import io.micronaut.context.annotation.Context; +import io.micronaut.context.annotation.Requires; +import io.micronaut.context.event.StartupEvent; +import io.micronaut.core.type.Argument; +import io.micronaut.health.HealthStatus; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.client.BlockingHttpClient; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.management.endpoint.health.DetailsVisibility; +import io.micronaut.management.endpoint.health.HealthLevelOfDetail; +import io.micronaut.runtime.server.EmbeddedServer; +import jakarta.inject.Singleton; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.*; + +class DeadlockedThreadsHealthIndicatorTest { + + @Test + void testDeadlockedThreadsHealthIndicator() { + Map configuration = Map.of( + "spec.name", "DeadlockedThreadsHealthIndicatorTest", + "endpoints.health.details-visible", DetailsVisibility.ANONYMOUS + ); + try (EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class, configuration)) { + try (HttpClient httpClient = server.getApplicationContext().createBean(HttpClient.class, server.getURL())) { + BlockingHttpClient client = httpClient.toBlocking(); + await().until(() -> isDown(client)); + Argument ok = Argument.of(Map.class); + Argument notOk = Argument.of(Map.class); + HttpClientResponseException ex = assertThrows(HttpClientResponseException.class, + () -> client.exchange(HttpRequest.GET("/health"), ok, notOk)); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatus()); + Optional healthStatusOptional = ex.getResponse().getBody(notOk); + assertNotNull(healthStatusOptional); + assertTrue(healthStatusOptional.isPresent()); + Map healthStatus = healthStatusOptional.get(); + assertNotNull(healthStatus); + assertEquals("DOWN", healthStatus.get("status")); + assertEquals("DOWN", ((Map) ((Map) healthStatus.get("details")).get("deadlockedThreads")).get("status")); + + Map details = (Map)((List) ((Map) ((Map) healthStatus.get("details")).get("deadlockedThreads")).get("details")).get(0); + assertTrue(details.containsKey("threadId")); + assertTrue(details.containsKey("threadName")); + assertTrue(details.containsKey("threadState")); + assertTrue(details.containsKey("daemon")); + assertTrue(details.containsKey("priority")); + assertTrue(details.containsKey("suspended")); + assertTrue(details.containsKey("inNative")); + assertTrue(details.containsKey("lockName")); + assertTrue(details.containsKey("lockOwnerName")); + assertTrue(details.containsKey("lockOwnerId")); + assertFalse(details.containsKey("lockedSynchronizers")); + assertTrue(details.containsKey("stackTrace")); + } + } + } + + private boolean isDown(BlockingHttpClient client) { + try { + client.exchange("/health"); + return false; + } catch (HttpClientResponseException e) { + return true; + } + } + + @Requires(property = "spec.name", value = "DeadlockedThreadsHealthIndicatorTest") + @Singleton + static class DeadLockBean implements io.micronaut.context.event.ApplicationEventListener { + private static final Logger LOG = LoggerFactory.getLogger(DeadLockBean.class); + public void onApplicationEvent(StartupEvent event) { + final Object lock1 = new Object(); + final Object lock2 = new Object(); + + // Thread 1 + Thread thread1 = new Thread(() -> { + synchronized (lock1) { + LOG.trace("Thread 1: Holding lock 1"); + + try { + // Introducing a delay to make deadlock more likely + Thread.sleep(2000); + } catch (InterruptedException e) { + LOG.error("", e); + } + + synchronized (lock2) { + LOG.trace("Thread 1: Holding lock 1 and lock 2"); + } + } + }); + // Thread 2 + Thread thread2 = new Thread(() -> { + synchronized (lock2) { + LOG.trace("Thread 2: Holding lock 2"); + + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + LOG.error("", e); + } + + synchronized (lock1) { + LOG.trace("Thread 2: Holding lock 2 and lock 1"); + } + } + }); + thread1.start(); + thread2.start(); + } + } +} \ No newline at end of file diff --git a/src/main/docs/guide/management/providedEndpoints/healthEndpoint/deadlockedThreads.adoc b/src/main/docs/guide/management/providedEndpoints/healthEndpoint/deadlockedThreads.adoc new file mode 100644 index 0000000000..538c35dd88 --- /dev/null +++ b/src/main/docs/guide/management/providedEndpoints/healthEndpoint/deadlockedThreads.adoc @@ -0,0 +1,51 @@ +The deadlocked threads health indicator uses the link:{jdkapi}/java.management/java/lang/management/ThreadMXBean.html[ThreadMXBean] to check for deadlocked threads and is part of the `/health` and `/health/liveness` endpoints. + +Its only configuration option is to enable or disable the indicator by the `endpoints.health.deadlocked-thread.enabled` key. It is enabled by default. + +WARNING: https://github.com/oracle/graal/issues/6101[`ThreadMXBean` is not supported in GraalVM Native Image] + +The health status is set to api:health.HealthStatus#DOWN[DOWN] if any deadlocked threads are found and their link:{jdkapi}/java.management/java/lang/management/ThreadInfo.html[ThreadInfo] including a formatted stacktrace are given in the details. See below for an example. + +[source,json] +---- +{ + "name": "example-app", + "status": "DOWN", + "details": { + "deadlockedThreads": { + "name": "example-app", + "status": "DOWN", + "details": [ + { + "threadId": "60", + "threadName": "Thread-0", + "threadState": "BLOCKED", + "daemon": "false", + "priority": "5", + "suspended": "false", + "inNative": "false", + "lockName": "java.lang.Object@7d10b1ca", + "lockOwnerName": "Thread-1", + "lockOwnerId": "61", + "lockedSynchronizers": [], + "stackTrace": "app//com.example.Deadlock.lambda$new$0(Deadlock.java:27)\n- blocked on java.lang.Object@7d10b1ca\n- locked java.lang.Object@4505ea74\napp//com.example.Deadlock$$Lambda/0x000001906948b360.run(Unknown Source)\njava.base@21/java.lang.Thread.runWith(Thread.java:1596)\njava.base@21/java.lang.Thread.run(Thread.java:1583)\n" + }, + { + "threadId": "61", + "threadName": "Thread-1", + "threadState": "BLOCKED", + "daemon": "false", + "priority": "5", + "suspended": "false", + "inNative": "false", + "lockName": "java.lang.Object@4505ea74", + "lockOwnerName": "Thread-0", + "lockOwnerId": "60", + "lockedSynchronizers": [], + "stackTrace": "app//com.example.Deadlock.lambda$new$1(Deadlock.java:43)\n- blocked on java.lang.Object@4505ea74\n- locked java.lang.Object@7d10b1ca\napp//com.example.Deadlock$$Lambda/0x000001906948b580.run(Unknown Source)\njava.base@21/java.lang.Thread.runWith(Thread.java:1596)\njava.base@21/java.lang.Thread.run(Thread.java:1583)\n" + } + ] + } + } +} +---- \ No newline at end of file diff --git a/src/main/docs/guide/toc.yml b/src/main/docs/guide/toc.yml index c40b29a1df..f4578fcb7a 100644 --- a/src/main/docs/guide/toc.yml +++ b/src/main/docs/guide/toc.yml @@ -330,7 +330,9 @@ management: title: Built-In Endpoints beansEndpoint: The Beans Endpoint infoEndpoint: The Info Endpoint - healthEndpoint: The Health Endpoint + healthEndpoint: + title: The Health Endpoint + deadlockedThreads: Deadlocked Threads metricsEndpoint: The Metrics Endpoint refreshEndpoint: The Refresh Endpoint routesEndpoint: The Routes Endpoint