diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/neo4j/Neo4jDriverMetricsAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/neo4j/Neo4jDriverMetricsAutoConfiguration.java new file mode 100644 index 0000000000..17f5f2c838 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/neo4j/Neo4jDriverMetricsAutoConfiguration.java @@ -0,0 +1,78 @@ +/* + * Copyright 2012-2020 the original author or 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 org.springframework.boot.actuate.autoconfigure.neo4j; + +import io.micrometer.core.instrument.MeterRegistry; + +import java.util.Collections; +import java.util.Map; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.neo4j.driver.Driver; +import org.springframework.boot.actuate.neo4j.Neo4jDriverMetrics; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.metrics.export.simple.SimpleMetricsExportAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.neo4j.Neo4jDriverAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for metrics on all available + * {@link Driver drivers}. + *

+ * The reason we are doing this dance with the manual binding is the fact that this + * autoconfiguration should work with more than one instance of the driver. If a user has + * multiple instances configured, than each instance should be bound via the binder to + * registry. Without that requirement, we could just add a {@link Bean @Bean} of type + * {@link Neo4jDriverMetrics} to the context and be done. + * + * @author Michael J. Simons + * @since 2.4.0 + */ +@Configuration(proxyBeanMethods = false) +@AutoConfigureAfter({ MetricsAutoConfiguration.class, Neo4jDriverAutoConfiguration.class, + SimpleMetricsExportAutoConfiguration.class }) +@ConditionalOnClass({ Driver.class, MeterRegistry.class }) +@ConditionalOnBean({ Driver.class, MeterRegistry.class }) +public class Neo4jDriverMetricsAutoConfiguration { + + private static final Log logger = LogFactory.getLog(Neo4jDriverMetricsAutoConfiguration.class); + + @Autowired + public void bindDataSourcesToRegistry(Map drivers, MeterRegistry registry) { + + drivers.forEach((name, driver) -> { + if (!Neo4jDriverMetrics.metricsAreEnabled(driver)) { + return; + } + driver.verifyConnectivityAsync() + .thenRunAsync(() -> new Neo4jDriverMetrics(name, driver, Collections.emptyList()).bindTo(registry)) + .exceptionally(e -> { + logger.warn("Could not verify connection for " + driver + " and thus not bind to metrics: " + + e.getMessage()); + return null; + }); + }); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/neo4j/Neo4jHealthContributorAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/neo4j/Neo4jHealthContributorAutoConfiguration.java index 9f24c18323..48a8ee9382 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/neo4j/Neo4jHealthContributorAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/neo4j/Neo4jHealthContributorAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2020 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,42 +16,77 @@ package org.springframework.boot.actuate.autoconfigure.neo4j; -import java.util.Map; - -import org.neo4j.ogm.session.SessionFactory; - import org.springframework.boot.actuate.autoconfigure.health.CompositeHealthContributorConfiguration; +import org.springframework.boot.actuate.autoconfigure.health.CompositeReactiveHealthContributorConfiguration; import org.springframework.boot.actuate.autoconfigure.health.ConditionalOnEnabledHealthIndicator; +import org.springframework.boot.actuate.autoconfigure.health.HealthContributorAutoConfiguration; +import org.springframework.boot.actuate.health.Health; import org.springframework.boot.actuate.health.HealthContributor; +import org.springframework.boot.actuate.health.ReactiveHealthContributor; import org.springframework.boot.actuate.neo4j.Neo4jHealthIndicator; +import org.springframework.boot.actuate.neo4j.Neo4jReactiveHealthIndicator; import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.AutoConfigureBefore; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.data.neo4j.Neo4jDataAutoConfiguration; +import org.springframework.boot.autoconfigure.neo4j.Neo4jDriverAutoConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import reactor.core.publisher.Flux; + +import java.util.Map; + +import org.neo4j.driver.Driver; /** * {@link EnableAutoConfiguration Auto-configuration} for {@link Neo4jHealthIndicator}. + * The auto-configuration here is responsible for both imperative and reactive health + * checks. The reactive health check has precedence over the imperative one. * * @author Eric Spiegelberg * @author Stephane Nicoll + * @author Michael J. Simons * @since 2.0.0 */ @Configuration(proxyBeanMethods = false) -@ConditionalOnClass(SessionFactory.class) -@ConditionalOnBean(SessionFactory.class) +@ConditionalOnClass({ Driver.class, Health.class }) +@ConditionalOnBean(Driver.class) @ConditionalOnEnabledHealthIndicator("neo4j") -@AutoConfigureAfter(Neo4jDataAutoConfiguration.class) -public class Neo4jHealthContributorAutoConfiguration - extends CompositeHealthContributorConfiguration { +@AutoConfigureBefore(HealthContributorAutoConfiguration.class) +@AutoConfigureAfter({ Neo4jDriverAutoConfiguration.class, Neo4jDataAutoConfiguration.class }) +public class Neo4jHealthContributorAutoConfiguration { + + @Configuration(proxyBeanMethods = false) + @Order(-20) + static class Neo4jHealthIndicatorConfiguration + extends CompositeHealthContributorConfiguration { + + @Bean + // If Neo4jReactiveHealthIndicatorConfiguration kicked in, don't add the + // imperative version as well + @ConditionalOnMissingBean(name = "neo4jHealthContributor") + public HealthContributor neo4jHealthContributor(Map drivers) { + return createContributor(drivers); + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass({ Flux.class }) + @Order(-30) + static class Neo4jReactiveHealthIndicatorConfiguration + extends CompositeReactiveHealthContributorConfiguration { + + @Bean + @ConditionalOnMissingBean(name = "neo4jHealthContributor") + public ReactiveHealthContributor neo4jHealthContributor(Map drivers) { + return createComposite(drivers); + } - @Bean - @ConditionalOnMissingBean(name = { "neo4jHealthIndicator", "neo4jHealthContributor" }) - public HealthContributor neo4jHealthContributor(Map sessionFactories) { - return createContributor(sessionFactories); } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/neo4j/package-info.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/neo4j/package-info.java index 6e11d71c1f..7191701934 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/neo4j/package-info.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/neo4j/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2020 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/neo4j/Neo4jDriverMetricsAutoConfigurationTest.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/neo4j/Neo4jDriverMetricsAutoConfigurationTest.java new file mode 100644 index 0000000000..02eff7faeb --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/neo4j/Neo4jDriverMetricsAutoConfigurationTest.java @@ -0,0 +1,141 @@ +/* + * Copyright 2012-2020 the original author or 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 org.springframework.boot.actuate.autoconfigure.neo4j; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration; +import org.springframework.boot.actuate.neo4j.Neo4jDriverMetrics; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.assertj.AssertableApplicationContext; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.context.runner.ContextConsumer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import org.neo4j.driver.Driver; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.springframework.boot.actuate.autoconfigure.neo4j.Neo4jDriverMocks.mockDriverWithMetrics; +import static org.springframework.boot.actuate.autoconfigure.neo4j.Neo4jDriverMocks.mockDriverWithoutMetrics; + +/** + * @author Michael J. Simons + */ +class Neo4jDriverMetricsAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withConfiguration( + AutoConfigurations.of(MetricsAutoConfiguration.class, Neo4jDriverMetricsAutoConfiguration.class)); + + private final ContextConsumer assertNoInteractionsWithRegistry = ctx -> { + + if (ctx.getBeansOfType(MeterRegistry.class).isEmpty()) { + return; + } + + MeterRegistry mockedRegistry = ctx.getBean(MeterRegistry.class); + verify(mockedRegistry).config(); + verifyNoMoreInteractions(mockedRegistry); + }; + + @Nested + class NoMatches { + + @Test + void shouldRequireAllNeededClasses() { + contextRunner.withUserConfiguration(WithMeterRegistry.class) + .withClassLoader(new FilteredClassLoader(Driver.class)).run(assertNoInteractionsWithRegistry); + + contextRunner.withUserConfiguration(WithDriverWithMetrics.class) + .withClassLoader(new FilteredClassLoader(MeterRegistry.class)) + .run(assertNoInteractionsWithRegistry); + } + + @Test + void shouldRequireAllNeededBeans() { + contextRunner.withUserConfiguration(WithDriverWithMetrics.class).run(assertNoInteractionsWithRegistry); + + contextRunner.withUserConfiguration(WithMeterRegistry.class).run(assertNoInteractionsWithRegistry); + } + + @Test + void shouldRequireDriverWithMetrics() { + contextRunner.withUserConfiguration(WithDriverWithoutMetrics.class, WithMeterRegistry.class) + .run(assertNoInteractionsWithRegistry); + + } + + } + + @Nested + class Matches { + + @Test + void shouldRequireDriverWithMetrics() { + contextRunner.withUserConfiguration(WithDriverWithMetrics.class, WithMeterRegistry.class).run(ctx -> { + + // Wait a bit to let the completable future of the test that mocks + // connectiviy complete. + Thread.sleep(500L); + + MeterRegistry meterRegistry = ctx.getBean(MeterRegistry.class); + assertThat(meterRegistry.getMeters()).extracting(m -> m.getId().getName()) + .filteredOn(s -> s.startsWith(Neo4jDriverMetrics.PREFIX)).isNotEmpty(); + }); + } + + } + + @Configuration(proxyBeanMethods = false) + static class WithDriverWithMetrics { + + @Bean + Driver driver() { + return mockDriverWithMetrics(); + } + + } + + @Configuration(proxyBeanMethods = false) + static class WithDriverWithoutMetrics { + + @Bean + Driver driver() { + return mockDriverWithoutMetrics(); + } + + } + + @Configuration(proxyBeanMethods = false) + static class WithMeterRegistry { + + @Bean + MeterRegistry meterRegistry() { + + MeterRegistry meterRegistry = spy(SimpleMeterRegistry.class); + return meterRegistry; + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/neo4j/Neo4jDriverMocks.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/neo4j/Neo4jDriverMocks.java new file mode 100644 index 0000000000..e8331e2e0b --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/neo4j/Neo4jDriverMocks.java @@ -0,0 +1,62 @@ +/* + * Copyright 2012-2020 the original author or 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 org.springframework.boot.actuate.autoconfigure.neo4j; + +import java.util.Collections; +import java.util.concurrent.CompletableFuture; + +import org.neo4j.driver.ConnectionPoolMetrics; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Metrics; +import org.neo4j.driver.exceptions.ClientException; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Some predefined mocks, only to be used internally for tests. + * + * @author Michael J. Simons + */ +final class Neo4jDriverMocks { + + public static Driver mockDriverWithMetrics() { + ConnectionPoolMetrics p1 = mock(ConnectionPoolMetrics.class); + when(p1.id()).thenReturn("p1"); + + Metrics metrics = mock(Metrics.class); + when(metrics.connectionPoolMetrics()).thenReturn(Collections.singletonList(p1)); + + Driver driver = mock(Driver.class); + when(driver.metrics()).thenReturn(metrics); + + when(driver.verifyConnectivityAsync()).thenReturn(CompletableFuture.completedFuture(null)); + + return driver; + } + + public static Driver mockDriverWithoutMetrics() { + + Driver driver = mock(Driver.class); + when(driver.metrics()).thenThrow(ClientException.class); + return driver; + } + + private Neo4jDriverMocks() { + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/neo4j/Neo4jHealthContributorAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/neo4j/Neo4jHealthContributorAutoConfigurationTests.java index b768bc2faf..b2f6976bc3 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/neo4j/Neo4jHealthContributorAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/neo4j/Neo4jHealthContributorAutoConfigurationTests.java @@ -1,11 +1,14 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. * * 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 + * 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, @@ -13,79 +16,147 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.springframework.boot.actuate.autoconfigure.neo4j; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.neo4j.ogm.session.Session; -import org.neo4j.ogm.session.SessionFactory; - import org.springframework.boot.actuate.autoconfigure.health.HealthContributorAutoConfiguration; +import org.springframework.boot.actuate.health.AbstractHealthIndicator; +import org.springframework.boot.actuate.health.CompositeReactiveHealthContributor; import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.boot.actuate.health.PingHealthIndicator; import org.springframework.boot.actuate.neo4j.Neo4jHealthIndicator; +import org.springframework.boot.actuate.neo4j.Neo4jReactiveHealthIndicator; import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import reactor.core.publisher.Flux; + +import org.neo4j.driver.Driver; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.entry; import static org.mockito.Mockito.mock; +import static org.springframework.boot.actuate.autoconfigure.neo4j.Neo4jDriverMocks.mockDriverWithoutMetrics; /** * Tests for {@link Neo4jHealthContributorAutoConfiguration}. * * @author Phillip Webb * @author Stephane Nicoll + * @author Michael J. Simons */ class Neo4jHealthContributorAutoConfigurationTests { - private ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withUserConfiguration(Neo4jConfiguration.class).withConfiguration(AutoConfigurations - .of(Neo4jHealthContributorAutoConfiguration.class, HealthContributorAutoConfiguration.class)); + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(HealthContributorAutoConfiguration.class, + Neo4jHealthContributorAutoConfiguration.class)); + + @Nested + class NoMatches { + + @Test + void shouldRespectManagementDecisions() { + contextRunner.withUserConfiguration(WithDriver.class) + .withPropertyValues("management.health.neo4j.enabled=false") + .run(ctx -> assertThat(ctx).doesNotHaveBean(Neo4jHealthIndicator.class) + .doesNotHaveBean(Neo4jReactiveHealthIndicator.class) + .hasSingleBean(PingHealthIndicator.class)); + } + + @Test + void shouldRequireHealthClass() { + contextRunner.withUserConfiguration(WithDriver.class).withClassLoader(new FilteredClassLoader(Health.class)) + .run(ctx -> assertThat(ctx).doesNotHaveBean(Neo4jHealthIndicator.class) + .doesNotHaveBean(Neo4jReactiveHealthIndicator.class)); + } + + @Test + void shouldRequireDriverClass() { + contextRunner.withUserConfiguration(WithDriver.class).withClassLoader(new FilteredClassLoader(Driver.class)) + .run(ctx -> assertThat(ctx).doesNotHaveBean(Neo4jHealthIndicator.class) + .doesNotHaveBean(Neo4jReactiveHealthIndicator.class)); + } + + @Test + void shouldRequireDriverBean() { + contextRunner.run(ctx -> assertThat(ctx).doesNotHaveBean(Neo4jHealthIndicator.class) + .doesNotHaveBean(Neo4jReactiveHealthIndicator.class)); + } + + @Test + void shouldRequireHealthIndicatorClasses() { + contextRunner.withUserConfiguration(WithDriver.class) + .withClassLoader(new FilteredClassLoader(Health.class, Flux.class)) + .run(ctx -> assertThat(ctx).doesNotHaveBean(Neo4jHealthIndicator.class) + .doesNotHaveBean(Neo4jReactiveHealthIndicator.class)); + + contextRunner.withUserConfiguration(WithDriver.class).withClassLoader(new FilteredClassLoader(Flux.class)) + .run(ctx -> assertThat(ctx).hasSingleBean(Neo4jHealthIndicator.class) + .doesNotHaveBean(Neo4jReactiveHealthIndicator.class)); + } + + @Test + void defaultIndicatorCanBeReplaced() { + contextRunner.withUserConfiguration(WithDriver.class, WithCustomIndicator.class).run((context) -> { + assertThat(context).hasBean("neo4jHealthIndicator"); + Health health = context.getBean("neo4jHealthIndicator", HealthIndicator.class).health(); + assertThat(health.getDetails()).containsOnly(entry("test", true)); + }); + } + + } + + @Nested + class Matches { + + @Test + void shouldCreateHealthIndicator() { + contextRunner.withUserConfiguration(WithDriver.class).withClassLoader(new FilteredClassLoader(Flux.class)) + .run(ctx -> assertThat(ctx).hasSingleBean(Neo4jHealthIndicator.class) + .doesNotHaveBean(Neo4jReactiveHealthIndicator.class)); + } - @Test - void runShouldCreateIndicator() { - this.contextRunner.run((context) -> assertThat(context).hasSingleBean(Neo4jHealthIndicator.class)); } @Test - void runWhenDisabledShouldNotCreateIndicator() { - this.contextRunner.withPropertyValues("management.health.neo4j.enabled:false") - .run((context) -> assertThat(context).doesNotHaveBean(Neo4jHealthIndicator.class)); - } + void reactiveHealthCheckShouldHavePrecedence() { + contextRunner.withUserConfiguration(WithDriver.class).run(ctx -> { + assertThat(ctx).doesNotHaveBean(Neo4jHealthIndicator.class).hasBean("neo4jHealthContributor"); - @Test - void defaultIndicatorCanBeReplaced() { - this.contextRunner.withUserConfiguration(CustomIndicatorConfiguration.class).run((context) -> { - assertThat(context).hasSingleBean(Neo4jHealthIndicator.class); - Health health = context.getBean(Neo4jHealthIndicator.class).health(); - assertThat(health.getDetails()).containsOnly(entry("test", true)); - }); + assertThat(ctx).getBean("neo4jHealthContributor").isInstanceOf(CompositeReactiveHealthContributor.class) + .satisfies(o -> { + CompositeReactiveHealthContributor chc = (CompositeReactiveHealthContributor) o; + assertThat(chc.getContributor("driver")).isInstanceOf(Neo4jReactiveHealthIndicator.class); + }); + } + + ); } @Configuration(proxyBeanMethods = false) - static class Neo4jConfiguration { + static class WithDriver { @Bean - SessionFactory sessionFactory() { - return mock(SessionFactory.class); + Driver driver() { + return mockDriverWithoutMetrics(); } } @Configuration(proxyBeanMethods = false) - static class CustomIndicatorConfiguration { + static class WithCustomIndicator { @Bean - Neo4jHealthIndicator neo4jHealthIndicator(SessionFactory sessionFactory) { - return new Neo4jHealthIndicator(sessionFactory) { + HealthIndicator neo4jHealthIndicator() { + return new AbstractHealthIndicator() { - @Override - protected void extractResult(Session session, Health.Builder builder) { + protected void doHealthCheck(Health.Builder builder) throws Exception { builder.up().withDetail("test", true); } - }; } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/neo4j/Neo4jDriverMetrics.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/neo4j/Neo4jDriverMetrics.java new file mode 100644 index 0000000000..65ebb6395f --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/neo4j/Neo4jDriverMetrics.java @@ -0,0 +1,124 @@ +/* + * Copyright 2012-2020 the original author or 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 org.springframework.boot.actuate.neo4j; + +import java.util.function.Consumer; + +import io.micrometer.core.instrument.FunctionCounter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.binder.MeterBinder; +import org.neo4j.driver.ConnectionPoolMetrics; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Metrics; +import org.neo4j.driver.exceptions.ClientException; + +import org.springframework.util.Assert; + +/** + * This is a {@link MeterBinder} that binds all available Neo4j driver metrics to + * Micrometer. + * + * @author Michael J. Simons + * @since 2.4.0 + */ +public final class Neo4jDriverMetrics implements MeterBinder { + + /** + * Prefixed used for driver metrics. + */ + public static final String PREFIX = "neo4j.driver.connections"; + + private static final String BASE_UNIT_CONNECTIONS = "connections"; + + private final Driver driver; + + private final Iterable tags; + + public Neo4jDriverMetrics(String name, Driver driver, Iterable tags) { + + Assert.notNull(name, "Bean name must not be null"); + Assert.notNull(driver, "Driver must not be null"); + Assert.notNull(tags, "Tags must not be null (but may be empty)"); + this.driver = driver; + this.tags = Tags.concat(tags, "name", name); + } + + @Override + public void bindTo(MeterRegistry meterRegistry) { + + Metrics metrics = this.driver.metrics(); + metrics.connectionPoolMetrics().forEach(this.getPoolMetricsBinder(meterRegistry)); + } + + Consumer getPoolMetricsBinder(MeterRegistry meterRegistry) { + return (poolMetrics) -> { + Iterable poolTags = Tags.concat(this.tags, "poolId", poolMetrics.id()); + + FunctionCounter.builder(PREFIX + ".acquired", poolMetrics, ConnectionPoolMetrics::acquired).tags(poolTags) + .baseUnit(BASE_UNIT_CONNECTIONS).description("The amount of connections that have been acquired.") + .register(meterRegistry); + + FunctionCounter.builder(PREFIX + ".closed", poolMetrics, ConnectionPoolMetrics::closed).tags(poolTags) + .baseUnit(BASE_UNIT_CONNECTIONS).description("The amount of connections have been closed.") + .register(meterRegistry); + + FunctionCounter.builder(PREFIX + ".created", poolMetrics, ConnectionPoolMetrics::created).tags(poolTags) + .baseUnit(BASE_UNIT_CONNECTIONS).description("The amount of connections have ever been created.") + .register(meterRegistry); + + FunctionCounter.builder(PREFIX + ".failedToCreate", poolMetrics, ConnectionPoolMetrics::failedToCreate) + .tags(poolTags).baseUnit(BASE_UNIT_CONNECTIONS) + .description("The amount of connections have been failed to create.").register(meterRegistry); + + Gauge.builder(PREFIX + ".idle", poolMetrics, ConnectionPoolMetrics::idle).tags(poolTags) + .baseUnit(BASE_UNIT_CONNECTIONS).description("The amount of connections that are currently idle.") + .register(meterRegistry); + + Gauge.builder(PREFIX + ".inUse", poolMetrics, ConnectionPoolMetrics::inUse).tags(poolTags) + .baseUnit(BASE_UNIT_CONNECTIONS).description("The amount of connections that are currently in-use.") + .register(meterRegistry); + + FunctionCounter + .builder(PREFIX + ".timedOutToAcquire", poolMetrics, ConnectionPoolMetrics::timedOutToAcquire) + .tags(poolTags).baseUnit(BASE_UNIT_CONNECTIONS) + .description( + "The amount of failures to acquire a connection from a pool within maximum connection acquisition timeout.") + .register(meterRegistry); + }; + } + + /** + * Utility method to check whether driver metrics are enabled without throwing an + * exception. + * @param driver the bean to check whether it has metrics enabled or not + * @return true, if the given bean exposes metrics + */ + public static boolean metricsAreEnabled(Driver driver) { + + try { + driver.metrics(); + return true; + } + catch (ClientException ex) { + return false; + } + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/neo4j/Neo4jHealthIndicator.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/neo4j/Neo4jHealthIndicator.java index 3f972070b5..1239594154 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/neo4j/Neo4jHealthIndicator.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/neo4j/Neo4jHealthIndicator.java @@ -16,61 +16,117 @@ package org.springframework.boot.actuate.neo4j; -import java.util.Collections; - -import org.neo4j.ogm.model.Result; -import org.neo4j.ogm.session.Session; -import org.neo4j.ogm.session.SessionFactory; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.neo4j.driver.AccessMode; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Result; +import org.neo4j.driver.Session; +import org.neo4j.driver.SessionConfig; +import org.neo4j.driver.exceptions.SessionExpiredException; +import org.neo4j.driver.summary.DatabaseInfo; +import org.neo4j.driver.summary.ResultSummary; +import org.neo4j.driver.summary.ServerInfo; import org.springframework.boot.actuate.health.AbstractHealthIndicator; import org.springframework.boot.actuate.health.Health; -import org.springframework.boot.actuate.health.Health.Builder; import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.util.StringUtils; /** * {@link HealthIndicator} that tests the status of a Neo4j by executing a Cypher - * statement. + * statement and extracting server and database information. * * @author Eric Spiegelberg * @author Stephane Nicoll + * @author Michael J. Simons * @since 2.0.0 */ public class Neo4jHealthIndicator extends AbstractHealthIndicator { + private static final Log logger = LogFactory.getLog(Neo4jHealthIndicator.class); + /** * The Cypher statement used to verify Neo4j is up. */ - static final String CYPHER = "CALL dbms.components() YIELD versions, edition" - + " UNWIND versions as version return version, edition"; - - private final SessionFactory sessionFactory; + static final String CYPHER = "CALL dbms.components() YIELD name, edition WHERE name = 'Neo4j Kernel' RETURN edition"; /** - * Create a new {@link Neo4jHealthIndicator} using the specified - * {@link SessionFactory}. - * @param sessionFactory the SessionFactory + * Message indicating that the health check failed. */ - public Neo4jHealthIndicator(SessionFactory sessionFactory) { - super("Neo4J health check failed"); - this.sessionFactory = sessionFactory; + static final String MESSAGE_HEALTH_CHECK_FAILED = "Neo4j health check failed"; + + /** + * Message logged before retrying a health check. + */ + static final String MESSAGE_SESSION_EXPIRED = "Neo4j session has expired, retrying one single time to retrieve server health."; + + /** + * The default session config to use while connecting. + */ + static final SessionConfig DEFAULT_SESSION_CONFIG = SessionConfig.builder().withDefaultAccessMode(AccessMode.WRITE) + .build(); + + /** + * The driver for this health indicator instance. + */ + private final Driver driver; + + public Neo4jHealthIndicator(Driver driver) { + super(MESSAGE_HEALTH_CHECK_FAILED); + this.driver = driver; } @Override - protected void doHealthCheck(Health.Builder builder) throws Exception { - Session session = this.sessionFactory.openSession(); - extractResult(session, builder); + protected void doHealthCheck(Health.Builder builder) { + + try { + ResultSummaryWithEdition resultSummaryWithEdition; + // Retry one time when the session has been expired + try { + resultSummaryWithEdition = runHealthCheckQuery(); + } + catch (SessionExpiredException sessionExpiredException) { + logger.warn(MESSAGE_SESSION_EXPIRED); + resultSummaryWithEdition = runHealthCheckQuery(); + } + buildStatusUp(resultSummaryWithEdition, builder); + } + catch (Exception ex) { + builder.down().withException(ex); + } } /** - * Provide health details using the specified {@link Session} and {@link Builder - * Builder}. - * @param session the session to use to execute a cypher statement - * @param builder the builder to add details to - * @throws Exception if getting health details failed + * Applies the given {@link ResultSummary} to the {@link Health.Builder builder} + * without actually calling {@code build}. + * @param resultSummaryWithEdition the result summary returned by the server + * @param builder the health builder to be modified + * @return the modified health builder */ - protected void extractResult(Session session, Health.Builder builder) throws Exception { - Result result = session.query(CYPHER, Collections.emptyMap()); - builder.up().withDetails(result.queryResults().iterator().next()); + static Health.Builder buildStatusUp(ResultSummaryWithEdition resultSummaryWithEdition, Health.Builder builder) { + ServerInfo serverInfo = resultSummaryWithEdition.resultSummary.server(); + DatabaseInfo databaseInfo = resultSummaryWithEdition.resultSummary.database(); + + builder.up().withDetail("server", serverInfo.version() + "@" + serverInfo.address()).withDetail("edition", + resultSummaryWithEdition.edition); + + if (StringUtils.hasText(databaseInfo.name())) { + builder.withDetail("database", databaseInfo.name()); + } + + return builder; + } + + ResultSummaryWithEdition runHealthCheckQuery() { + // We use WRITE here to make sure UP is returned for a server that supports + // all possible workloads + try (Session session = this.driver.session(DEFAULT_SESSION_CONFIG)) { + Result result = session.run(CYPHER); + String edition = result.single().get("edition").asString(); + ResultSummary resultSummary = result.consume(); + return new ResultSummaryWithEdition(resultSummary, edition); + } } } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/neo4j/Neo4jReactiveHealthIndicator.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/neo4j/Neo4jReactiveHealthIndicator.java new file mode 100644 index 0000000000..535fcc4043 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/neo4j/Neo4jReactiveHealthIndicator.java @@ -0,0 +1,71 @@ +/* + * Copyright 2012-2020 the original author or 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 org.springframework.boot.actuate.neo4j; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.neo4j.driver.Driver; +import org.neo4j.driver.exceptions.SessionExpiredException; +import org.neo4j.driver.reactive.RxResult; +import org.neo4j.driver.reactive.RxSession; +import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; + +import org.springframework.boot.actuate.health.AbstractReactiveHealthIndicator; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.ReactiveHealthIndicator; + +/** + * {@link ReactiveHealthIndicator} that tests the status of a Neo4j by executing a Cypher + * statement and extracting server and database information. + * + * @author Michael J. Simons + * @since 2.4.0 + */ +public final class Neo4jReactiveHealthIndicator extends AbstractReactiveHealthIndicator { + + private static final Log logger = LogFactory.getLog(Neo4jReactiveHealthIndicator.class); + + /** + * The driver for this health indicator instance. + */ + private final Driver driver; + + public Neo4jReactiveHealthIndicator(Driver driver) { + this.driver = driver; + } + + @Override + protected Mono doHealthCheck(Health.Builder builder) { + return runHealthCheckQuery() + .doOnError(SessionExpiredException.class, + (e) -> logger.warn(Neo4jHealthIndicator.MESSAGE_SESSION_EXPIRED)) + .retryWhen(Retry.max(1).filter(SessionExpiredException.class::isInstance)) + .map((r) -> Neo4jHealthIndicator.buildStatusUp(r, builder).build()); + } + + Mono runHealthCheckQuery() { + // We use WRITE here to make sure UP is returned for a server that supports + // all possible workloads + return Mono.using(() -> this.driver.rxSession(Neo4jHealthIndicator.DEFAULT_SESSION_CONFIG), (session) -> { + RxResult result = session.run(Neo4jHealthIndicator.CYPHER); + return Mono.from(result.records()).map((record) -> record.get("edition").asString()) + .zipWhen((edition) -> Mono.from(result.consume()), (e, r) -> new ResultSummaryWithEdition(r, e)); + }, RxSession::close); + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/neo4j/ResultSummaryWithEdition.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/neo4j/ResultSummaryWithEdition.java new file mode 100644 index 0000000000..9220a5d525 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/neo4j/ResultSummaryWithEdition.java @@ -0,0 +1,37 @@ +/* + * Copyright 2012-2020 the original author or 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 org.springframework.boot.actuate.neo4j; + +import org.neo4j.driver.summary.ResultSummary; + +/** + * A holder for a {@link ResultSummary result summary} and database edition. + * + * @author Michael J. Simons + */ +final class ResultSummaryWithEdition { + + final ResultSummary resultSummary; + + final String edition; + + ResultSummaryWithEdition(ResultSummary resultSummary, String edition) { + this.resultSummary = resultSummary; + this.edition = edition; + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/neo4j/package-info.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/neo4j/package-info.java index 340170eff5..7b89b60097 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/neo4j/package-info.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/neo4j/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2020 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/neo4j/Neo4jDriverMetricsTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/neo4j/Neo4jDriverMetricsTests.java new file mode 100644 index 0000000000..7b85151d65 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/neo4j/Neo4jDriverMetricsTests.java @@ -0,0 +1,66 @@ +/* + * Copyright 2012-2020 the original author or 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 org.springframework.boot.actuate.neo4j; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +import org.neo4j.driver.Driver; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.actuate.neo4j.Neo4jDriverMetrics.PREFIX; +import static org.springframework.boot.actuate.neo4j.Neo4jDriverMocks.mockDriverWithMetrics; +import static org.springframework.boot.actuate.neo4j.Neo4jDriverMocks.mockDriverWithoutMetrics; + +/** + * @author Michael J. Simons + */ +class Neo4jDriverMetricsTests { + + @Test + void shouldDetectEnabledMetrics() { + + Driver driver = mockDriverWithMetrics(); + assertThat(Neo4jDriverMetrics.metricsAreEnabled(driver)).isTrue(); + } + + @Test + void shouldDetectDisabledMetrics() { + + Driver driver = mockDriverWithoutMetrics(); + assertThat(Neo4jDriverMetrics.metricsAreEnabled(driver)).isFalse(); + } + + @Test + void shouldRegisterCorrectMeters() { + + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + Neo4jDriverMetrics metrics = new Neo4jDriverMetrics("driver", mockDriverWithMetrics(), Collections.emptyList()); + metrics.bindTo(registry); + + assertThat(registry.get(PREFIX + ".acquired").functionCounter()).isNotNull(); + assertThat(registry.get(PREFIX + ".closed").functionCounter()).isNotNull(); + assertThat(registry.get(PREFIX + ".created").functionCounter()).isNotNull(); + assertThat(registry.get(PREFIX + ".failedToCreate").functionCounter()).isNotNull(); + assertThat(registry.get(PREFIX + ".idle").gauge()).isNotNull(); + assertThat(registry.get(PREFIX + ".inUse").gauge()).isNotNull(); + assertThat(registry.get(PREFIX + ".timedOutToAcquire").functionCounter()).isNotNull(); + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/neo4j/Neo4jDriverMocks.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/neo4j/Neo4jDriverMocks.java new file mode 100644 index 0000000000..f6e44d5fdc --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/neo4j/Neo4jDriverMocks.java @@ -0,0 +1,62 @@ +/* + * Copyright 2012-2020 the original author or 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 org.springframework.boot.actuate.neo4j; + +import java.util.Collections; +import java.util.concurrent.CompletableFuture; + +import org.neo4j.driver.ConnectionPoolMetrics; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Metrics; +import org.neo4j.driver.exceptions.ClientException; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Some predefined mocks, only to be used internally for tests. + * + * @author Michael J. Simons + */ +final class Neo4jDriverMocks { + + public static Driver mockDriverWithMetrics() { + ConnectionPoolMetrics p1 = mock(ConnectionPoolMetrics.class); + when(p1.id()).thenReturn("p1"); + + Metrics metrics = mock(Metrics.class); + when(metrics.connectionPoolMetrics()).thenReturn(Collections.singletonList(p1)); + + Driver driver = mock(Driver.class); + when(driver.metrics()).thenReturn(metrics); + + when(driver.verifyConnectivityAsync()).thenReturn(CompletableFuture.completedFuture(null)); + + return driver; + } + + public static Driver mockDriverWithoutMetrics() { + + Driver driver = mock(Driver.class); + when(driver.metrics()).thenThrow(ClientException.class); + return driver; + } + + private Neo4jDriverMocks() { + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/neo4j/Neo4jHealthIndicatorTestBase.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/neo4j/Neo4jHealthIndicatorTestBase.java new file mode 100644 index 0000000000..036ce85254 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/neo4j/Neo4jHealthIndicatorTestBase.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2019-2020 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 org.springframework.boot.actuate.neo4j; + +import org.mockito.Mock; + +import org.neo4j.driver.Driver; +import org.neo4j.driver.Record; +import org.neo4j.driver.Values; +import org.neo4j.driver.summary.DatabaseInfo; +import org.neo4j.driver.summary.ResultSummary; +import org.neo4j.driver.summary.ServerInfo; + +import static org.mockito.Mockito.when; + +/** + * Contains some shared mocks for the health indicator tests. + * + * @author Michael J. Simons + */ +abstract class Neo4jHealthIndicatorTestBase { + + @Mock + protected Driver driver; + + @Mock + protected ResultSummary resultSummary; + + @Mock + protected ServerInfo serverInfo; + + @Mock + protected DatabaseInfo databaseInfo; + + @Mock + protected Record record; + + protected void prepareSharedMocks() { + + when(serverInfo.version()).thenReturn("4711"); + when(serverInfo.address()).thenReturn("Zu Hause"); + when(databaseInfo.name()).thenReturn("n/a"); + when(resultSummary.server()).thenReturn(serverInfo); + when(resultSummary.database()).thenReturn(databaseInfo); + when(record.get("edition")).thenReturn(Values.value("ultimate collectors edition")); + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/neo4j/Neo4jHealthIndicatorTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/neo4j/Neo4jHealthIndicatorTests.java index 6eec0f5933..c81bd28923 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/neo4j/Neo4jHealthIndicatorTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/neo4j/Neo4jHealthIndicatorTests.java @@ -16,25 +16,29 @@ package org.springframework.boot.actuate.neo4j; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.neo4j.ogm.exception.CypherException; -import org.neo4j.ogm.model.Result; -import org.neo4j.ogm.session.Session; -import org.neo4j.ogm.session.SessionFactory; - +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.boot.actuate.health.Health; import org.springframework.boot.actuate.health.Status; +import java.util.concurrent.atomic.AtomicInteger; + +import org.neo4j.driver.Result; +import org.neo4j.driver.Session; +import org.neo4j.driver.SessionConfig; +import org.neo4j.driver.Values; +import org.neo4j.driver.exceptions.ServiceUnavailableException; +import org.neo4j.driver.exceptions.SessionExpiredException; + import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; /** * Tests for {@link Neo4jHealthIndicator}. @@ -43,48 +47,126 @@ import static org.mockito.Mockito.mock; * @author Stephane Nicoll * @author Michael Simons */ -class Neo4jHealthIndicatorTests { +@ExtendWith(MockitoExtension.class) +class Neo4jHealthIndicatorTests extends Neo4jHealthIndicatorTestBase { + @Mock private Session session; - private Neo4jHealthIndicator neo4jHealthIndicator; - - @BeforeEach - void before() { - this.session = mock(Session.class); - SessionFactory sessionFactory = mock(SessionFactory.class); - given(sessionFactory.openSession()).willReturn(this.session); - this.neo4jHealthIndicator = new Neo4jHealthIndicator(sessionFactory); - } + @Mock + private Result statementResult; @Test - void neo4jUp() { - Result result = mock(Result.class); - given(this.session.query(Neo4jHealthIndicator.CYPHER, Collections.emptyMap())).willReturn(result); - Map expectedCypherDetails = new HashMap<>(); - String edition = "community"; - String version = "4.0.0"; - expectedCypherDetails.put("edition", edition); - expectedCypherDetails.put("version", version); - List> queryResults = new ArrayList<>(); - queryResults.add(expectedCypherDetails); - given(result.queryResults()).willReturn(queryResults); - Health health = this.neo4jHealthIndicator.health(); + void shouldWorkWithoutDatabaseName() { + when(this.serverInfo.version()).thenReturn("4711"); + when(this.serverInfo.address()).thenReturn("Zu Hause"); + when(this.resultSummary.server()).thenReturn(this.serverInfo); + when(this.resultSummary.database()).thenReturn(this.databaseInfo); + + when(this.databaseInfo.name()).thenReturn(null); + + when(record.get("edition")).thenReturn(Values.value("some edition")); + when(this.statementResult.single()).thenReturn(this.record); + when(this.statementResult.consume()).thenReturn(this.resultSummary); + when(this.session.run(anyString())).thenReturn(this.statementResult); + + when(this.driver.session(any(SessionConfig.class))).thenReturn(this.session); + + Neo4jHealthIndicator healthIndicator = new Neo4jHealthIndicator(this.driver); + Health health = healthIndicator.health(); assertThat(health.getStatus()).isEqualTo(Status.UP); - Map details = health.getDetails(); - String editionFromDetails = details.get("edition").toString(); - String versionFromDetails = details.get("version").toString(); - assertThat(editionFromDetails).isEqualTo(edition); - assertThat(versionFromDetails).isEqualTo(version); + assertThat(health.getDetails()).containsEntry("server", "4711@Zu Hause"); + assertThat(health.getDetails()).doesNotContainKey("database"); + assertThat(health.getDetails()).containsEntry("edition", "some edition"); } @Test - void neo4jDown() { - CypherException cypherException = new CypherException("Neo.ClientError.Statement.SyntaxError", - "Error executing Cypher"); - given(this.session.query(Neo4jHealthIndicator.CYPHER, Collections.emptyMap())).willThrow(cypherException); - Health health = this.neo4jHealthIndicator.health(); + void shouldWorkWithEmptyDatabaseName() { + when(this.serverInfo.version()).thenReturn("4711"); + when(this.serverInfo.address()).thenReturn("Zu Hause"); + when(this.resultSummary.server()).thenReturn(this.serverInfo); + when(this.resultSummary.database()).thenReturn(this.databaseInfo); + + when(this.databaseInfo.name()).thenReturn(""); + + when(record.get("edition")).thenReturn(Values.value("some edition")); + when(this.statementResult.single()).thenReturn(this.record); + when(this.statementResult.consume()).thenReturn(this.resultSummary); + when(this.session.run(anyString())).thenReturn(this.statementResult); + + when(driver.session(any(SessionConfig.class))).thenReturn(this.session); + + Neo4jHealthIndicator healthIndicator = new Neo4jHealthIndicator(this.driver); + Health health = healthIndicator.health(); + assertThat(health.getStatus()).isEqualTo(Status.UP); + assertThat(health.getDetails()).containsEntry("server", "4711@Zu Hause"); + assertThat(health.getDetails()).doesNotContainKey("database"); + assertThat(health.getDetails()).containsEntry("edition", "some edition"); + } + + @Test + void neo4jIsUp() { + + prepareSharedMocks(); + + when(this.statementResult.single()).thenReturn(this.record); + when(this.statementResult.consume()).thenReturn(this.resultSummary); + when(this.session.run(anyString())).thenReturn(this.statementResult); + + when(this.driver.session(any(SessionConfig.class))).thenReturn(this.session); + + Neo4jHealthIndicator healthIndicator = new Neo4jHealthIndicator(this.driver); + Health health = healthIndicator.health(); + assertThat(health.getStatus()).isEqualTo(Status.UP); + assertThat(health.getDetails()).containsEntry("server", "4711@Zu Hause"); + assertThat(health.getDetails()).containsEntry("database", "n/a"); + assertThat(health.getDetails()).containsEntry("edition", "ultimate collectors edition"); + + verify(session).close(); + verifyNoMoreInteractions(this.driver, this.session, this.statementResult, this.resultSummary, this.serverInfo, + this.databaseInfo); + } + + @Test + void neo4jSessionIsExpiredOnce() { + + AtomicInteger cnt = new AtomicInteger(0); + + prepareSharedMocks(); + when(this.statementResult.single()).thenReturn(this.record); + when(this.statementResult.consume()).thenReturn(this.resultSummary); + when(this.session.run(anyString())).thenAnswer(invocation -> { + if (cnt.compareAndSet(0, 1)) { + throw new SessionExpiredException("Session expired"); + } + return Neo4jHealthIndicatorTests.this.statementResult; + }); + when(driver.session(any(SessionConfig.class))).thenReturn(this.session); + + Neo4jHealthIndicator healthIndicator = new Neo4jHealthIndicator(this.driver); + Health health = healthIndicator.health(); + + assertThat(health.getStatus()).isEqualTo(Status.UP); + assertThat(health.getDetails()).containsEntry("server", "4711@Zu Hause"); + + verify(this.session, times(2)).close(); + verifyNoMoreInteractions(this.driver, this.session, this.statementResult, this.resultSummary, this.serverInfo, + this.databaseInfo); + } + + @Test + void neo4jSessionIsDown() { + + when(driver.session(any(SessionConfig.class))).thenThrow(ServiceUnavailableException.class); + + Neo4jHealthIndicator healthIndicator = new Neo4jHealthIndicator(driver); + Health health = healthIndicator.health(); + assertThat(health.getStatus()).isEqualTo(Status.DOWN); + assertThat(health.getDetails()).containsKeys("error"); + + verifyNoMoreInteractions(this.driver, this.session, this.statementResult, this.resultSummary, this.serverInfo, + this.databaseInfo); } } diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/neo4j/Neo4jReactiveHealthIndicatorTest.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/neo4j/Neo4jReactiveHealthIndicatorTest.java new file mode 100644 index 0000000000..4963b35f95 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/neo4j/Neo4jReactiveHealthIndicatorTest.java @@ -0,0 +1,117 @@ +/* + * Copyright 2012-2020 the original author or 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 org.springframework.boot.actuate.neo4j; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.actuate.health.Status; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.neo4j.driver.SessionConfig; +import org.neo4j.driver.exceptions.ServiceUnavailableException; +import org.neo4j.driver.exceptions.SessionExpiredException; +import org.neo4j.driver.reactive.RxResult; +import org.neo4j.driver.reactive.RxSession; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +/** + * @author Michael J. Simons + */ +@ExtendWith(MockitoExtension.class) +class Neo4jReactiveHealthIndicatorTest extends Neo4jHealthIndicatorTestBase { + + @Mock + private RxSession session; + + @Mock + private RxResult statementResult; + + @Test + void neo4jIsUp() { + + prepareSharedMocks(); + when(statementResult.records()).thenReturn(Mono.just(record)); + when(statementResult.consume()).thenReturn(Mono.just(resultSummary)); + when(session.run(anyString())).thenReturn(statementResult); + + when(driver.rxSession(any(SessionConfig.class))).thenReturn(session); + + Neo4jReactiveHealthIndicator healthIndicator = new Neo4jReactiveHealthIndicator(driver); + healthIndicator.health().as(StepVerifier::create).consumeNextWith(health -> { + assertThat(health.getStatus()).isEqualTo(Status.UP); + assertThat(health.getDetails()).containsEntry("server", "4711@Zu Hause"); + assertThat(health.getDetails()).containsEntry("edition", "ultimate collectors edition"); + }).verifyComplete(); + + verify(session).close(); + verifyNoMoreInteractions(driver, session, statementResult, resultSummary, serverInfo, databaseInfo); + } + + @Test + void neo4jSessionIsExpiredOnce() { + + AtomicInteger cnt = new AtomicInteger(0); + + prepareSharedMocks(); + when(statementResult.records()).thenReturn(Mono.just(record)); + when(statementResult.consume()).thenReturn(Mono.just(resultSummary)); + when(session.run(anyString())).thenAnswer(invocation -> { + if (cnt.compareAndSet(0, 1)) { + throw new SessionExpiredException("Session expired"); + } + return statementResult; + }); + when(driver.rxSession(any(SessionConfig.class))).thenReturn(session); + + Neo4jReactiveHealthIndicator healthIndicator = new Neo4jReactiveHealthIndicator(driver); + healthIndicator.health().as(StepVerifier::create).consumeNextWith(health -> { + assertThat(health.getStatus()).isEqualTo(Status.UP); + assertThat(health.getDetails()).containsEntry("server", "4711@Zu Hause"); + assertThat(health.getDetails()).containsEntry("edition", "ultimate collectors edition"); + }).verifyComplete(); + + verify(session, times(2)).close(); + verifyNoMoreInteractions(driver, session, statementResult, resultSummary, serverInfo, databaseInfo); + } + + @Test + void neo4jSessionIsDown() { + + when(driver.rxSession(any(SessionConfig.class))).thenThrow(ServiceUnavailableException.class); + + Neo4jReactiveHealthIndicator healthIndicator = new Neo4jReactiveHealthIndicator(driver); + healthIndicator.health().as(StepVerifier::create).consumeNextWith(health -> { + assertThat(health.getStatus()).isEqualTo(Status.DOWN); + assertThat(health.getDetails()).containsKeys("error"); + }).verifyComplete(); + + verifyNoMoreInteractions(driver, session, statementResult, resultSummary, serverInfo, databaseInfo); + } + +}