Polish "Make HikariDataSource participate in checkpoint-restore"

See gh-36422
This commit is contained in:
Andy Wilkinson 2023-07-25 16:27:31 +01:00
parent 9240f971fb
commit b476d368db
8 changed files with 256 additions and 328 deletions

View File

@ -67,7 +67,6 @@ dependencies {
optional("org.apache.tomcat:tomcat-jdbc")
optional("org.apiguardian:apiguardian-api")
optional("org.apache.groovy:groovy-templates")
optional("org.crac:crac:1.3.0")
optional("org.eclipse.angus:angus-mail")
optional("com.github.ben-manes.caffeine:caffeine")
optional("com.github.mxab.thymeleaf.extras:thymeleaf-extras-data-attribute")

View File

@ -25,12 +25,14 @@ import oracle.jdbc.OracleConnection;
import oracle.ucp.jdbc.PoolDataSourceImpl;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnCheckpointRestore;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.boot.jdbc.DatabaseDriver;
import org.springframework.boot.jdbc.HikariCheckpointRestoreLifecycle;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
@ -123,9 +125,9 @@ abstract class DataSourceConfiguration {
}
@Bean
@ConditionalOnClass(name = "org.crac.Resource")
HikariLifecycle dataSourceLifecycle(HikariDataSource hikariDataSource) {
return new HikariLifecycle(hikariDataSource);
@ConditionalOnCheckpointRestore
HikariCheckpointRestoreLifecycle hikariCheckpointRestoreLifecycle(HikariDataSource hikariDataSource) {
return new HikariCheckpointRestoreLifecycle(hikariDataSource);
}
}

View File

@ -1,225 +0,0 @@
/*
* Copyright 2023 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.autoconfigure.jdbc;
import java.lang.reflect.Field;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Function;
import javax.sql.DataSource;
import com.zaxxer.hikari.HikariConfigMXBean;
import com.zaxxer.hikari.HikariDataSource;
import com.zaxxer.hikari.HikariPoolMXBean;
import com.zaxxer.hikari.pool.HikariPool;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.boot.jdbc.DataSourceUnwrapper;
import org.springframework.context.Lifecycle;
import org.springframework.util.Assert;
import org.springframework.util.ReflectionUtils;
/**
* {@link Lifecycle} management for a {@link HikariDataSource} taking care of
* {@link Lifecycle#start() starting}/{@link Lifecycle#stop() stopping} the
* {@link javax.sql.DataSource} by {@link HikariDataSource#isAllowPoolSuspension()
* eventually} suspending/resuming the underlying {@link HikariPool connection pool} and
* {@link HikariPoolMXBean#softEvictConnections() evicting} open & idle connections.
*
* @author Christoph Strobl
*/
class HikariLifecycle implements Lifecycle {
private final HikariDataSource dataSource;
private final LifecycleExecutor lifecycleExecutor;
HikariLifecycle(HikariDataSource dataSource) {
this.dataSource = dataSource;
this.lifecycleExecutor = new LifecycleExecutor(dataSource);
}
HikariDataSource getManagedInstance() {
return this.dataSource;
}
@Override
public void start() {
if (this.dataSource.isRunning()) {
return;
}
if (this.dataSource.isClosed()) {
throw new IllegalStateException("DataSource has been closed and cannot be restarted");
}
this.lifecycleExecutor.resume();
}
@Override
public void stop() {
if (this.dataSource.isRunning()) {
this.lifecycleExecutor.pause();
}
}
@Override
public boolean isRunning() {
return this.dataSource.isRunning();
}
/**
* Component to help suspend/resume a {@link HikariDataSource} by taking the pool
* suspension flag into account. Will perform best effort to make sure connections
* reported as closed buy the {@link HikariPoolMXBean} have actually been closed by
* the {@link java.util.concurrent.Executor} that is in charge of closing them.
*
* @author Christoph Strobl
*/
private static class LifecycleExecutor {
private static final Log logger = LogFactory.getLog(LifecycleExecutor.class);
private static final Field CLOSE_CONNECTION_EXECUTOR;
private final HikariDataSource dataSource;
private final Function<HikariPool, Boolean> hasOpenConnections;
static {
Field closeConnectionExecutor = ReflectionUtils.findField(HikariPool.class, "closeConnectionExecutor");
Assert.notNull(closeConnectionExecutor, "Unable to locate closeConnectionExecutor for HikariPool");
Assert.isAssignable(ThreadPoolExecutor.class, closeConnectionExecutor.getType(),
"Expected ThreadPoolExecutor for closeConnectionExecutor but found %s"
.formatted(closeConnectionExecutor.getType()));
ReflectionUtils.makeAccessible(closeConnectionExecutor);
CLOSE_CONNECTION_EXECUTOR = closeConnectionExecutor;
}
LifecycleExecutor(HikariDataSource hikariDataSource) {
this.dataSource = getUltimateTargetObject(hikariDataSource);
this.hasOpenConnections = new Function<>() {
@Override
public Boolean apply(HikariPool pool) {
ThreadPoolExecutor closeConnectionExecutor = (ThreadPoolExecutor) ReflectionUtils
.getField(CLOSE_CONNECTION_EXECUTOR, pool);
if (closeConnectionExecutor == null) {
throw new IllegalStateException("CloseConnectionExecutor was null");
}
return closeConnectionExecutor.getActiveCount() > 0;
}
};
}
/**
* Pause the {@link HikariDataSource} and try to suspend obtaining new connections
* from the pool if possible. Will wait for connection to be closed. Default
* timeout is set to {@link HikariDataSource#getConnectionTimeout()} + 250 ms.
*/
void pause() {
pause(Duration.ofMillis(this.dataSource.getConnectionTimeout() + 250));
}
/**
* Pause the {@link HikariDataSource} and try to suspend obtaining new connections
* from the pool if possible. Wait at most the given {@literal shutdownTimeout}
* for connections to be closed.
* @param shutdownTimeout max timeout to wait for connections to be closed.
*/
void pause(Duration shutdownTimeout) {
if (this.dataSource.isAllowPoolSuspension()) {
logger.info("Suspending Hikari pool");
this.dataSource.getHikariPoolMXBean().suspendPool();
}
closeConnections(shutdownTimeout);
}
/**
* Resume the {@link HikariDataSource} by lifting the pool suspension if set.
*/
void resume() {
if (this.dataSource.isAllowPoolSuspension()) {
logger.info("Resuming Hikari pool");
this.dataSource.getHikariPoolMXBean().resumePool();
}
}
void closeConnections(Duration shutdownTimeout) {
logger.info("Evicting Hikari connections");
this.dataSource.getHikariPoolMXBean().softEvictConnections();
logger.debug("Waiting for Hikari connections to be closed");
CompletableFuture<Void> allConnectionsClosed = CompletableFuture.runAsync(this::waitForConnectionsToClose);
try {
allConnectionsClosed.get(shutdownTimeout.toMillis(), TimeUnit.MILLISECONDS);
logger.debug("Hikari connections closed");
}
catch (InterruptedException ex) {
logger.error("Interrupted while waiting for connections to be closed", ex);
Thread.currentThread().interrupt();
}
catch (TimeoutException ex) {
logger.error("Hikari connections could not be closed within %s".formatted(shutdownTimeout), ex);
}
catch (ExecutionException ex) {
throw new RuntimeException("Failed to close Hikari connections", ex);
}
}
private void waitForConnectionsToClose() {
if (!(this.dataSource.getHikariPoolMXBean() instanceof HikariPool pool)) {
throw new IllegalStateException(
"Expected HikariPool instance but was %s".formatted(this.dataSource.getHikariPoolMXBean()));
}
while (this.hasOpenConnections.apply(pool)) {
try {
TimeUnit.MILLISECONDS.sleep(50);
}
catch (InterruptedException ex) {
logger.error("Interrupted while waiting for datasource connections to be closed", ex);
Thread.currentThread().interrupt();
}
}
}
@SuppressWarnings("unchecked")
private static HikariDataSource getUltimateTargetObject(DataSource candidate) {
return DataSourceUnwrapper.unwrap(candidate, HikariConfigMXBean.class, HikariDataSource.class);
}
}
}

View File

@ -17,7 +17,6 @@
package org.springframework.boot.autoconfigure.jdbc;
import java.lang.management.ManagementFactory;
import java.sql.Connection;
import java.util.Set;
import java.util.UUID;
@ -36,7 +35,6 @@ import org.springframework.aop.framework.ProxyFactory;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.jmx.JmxAutoConfiguration;
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;
@ -109,8 +107,7 @@ class DataSourceJmxConfigurationTests {
assertThat(hikariDataSource.isRegisterMbeans()).isTrue();
// Ensure that the pool has been initialized, triggering MBean
// registration
Connection connection = hikariDataSource.getConnection();
hikariDataSource.evictConnection(connection);
hikariDataSource.getConnection().close();
// Hikari can still register mBeans
validateHikariMBeansRegistration(ManagementFactory.getPlatformMBeanServer(), poolName, true);
});
@ -135,21 +132,6 @@ class DataSourceJmxConfigurationTests {
});
}
@Test
void hikariAutoConfigRegistersLifecycleBean() {
this.contextRunner.withPropertyValues("spring.datasource.type=" + HikariDataSource.class.getName())
.run((context) -> assertThat(context).hasSingleBean(HikariLifecycle.class));
}
@Test
void hikariAutoConfigConditionallyRegistersLifecycleBean() {
this.contextRunner.withPropertyValues("spring.datasource.type=" + HikariDataSource.class.getName())
.withClassLoader(new FilteredClassLoader("org.crac"))
.run((context) -> assertThat(context).doesNotHaveBean(HikariLifecycle.class));
}
private void validateHikariMBeansRegistration(MBeanServer mBeanServer, String poolName, boolean expected)
throws MalformedObjectNameException {
assertThat(mBeanServer.isRegistered(new ObjectName("com.zaxxer.hikari:type=Pool (" + poolName + ")")))
@ -214,10 +196,7 @@ class DataSourceJmxConfigurationTests {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean instanceof javax.sql.DataSource) {
ProxyFactory pf = new ProxyFactory(bean);
pf.setProxyTargetClass(true);
return pf.getProxy();
return new ProxyFactory(bean).getProxy();
}
return bean;
}

View File

@ -23,7 +23,9 @@ import org.assertj.core.api.InstanceOfAssertFactories;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.jdbc.HikariCheckpointRestoreLifecycle;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.boot.testsupport.classpath.ClassPathOverrides;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -122,6 +124,19 @@ class HikariDataSourceConfigurationTests {
});
}
@Test
@ClassPathOverrides("org.crac:crac:1.3.0")
void whenCheckpointRestoreIsAvailableHikariAutoConfigRegistersLifecycleBean() {
this.contextRunner.withPropertyValues("spring.datasource.type=" + HikariDataSource.class.getName())
.run((context) -> assertThat(context).hasSingleBean(HikariCheckpointRestoreLifecycle.class));
}
@Test
void whenCheckpointRestoreIsNotAvailableHikariAutoConfigDoesNotRegisterLifecycleBean() {
this.contextRunner.withPropertyValues("spring.datasource.type=" + HikariDataSource.class.getName())
.run((context) -> assertThat(context).doesNotHaveBean(HikariCheckpointRestoreLifecycle.class));
}
@Configuration(proxyBeanMethods = false)
static class ConnectionDetailsConfiguration {

View File

@ -1,76 +0,0 @@
/*
* Copyright 2023 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.autoconfigure.jdbc;
import java.util.UUID;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
/**
* Tests for {@link HikariLifecycle}.
*
* @author Christoph Strobl
*/
class HikariLifecycleTests {
@Test
void stopStartHikariDataSource() {
HikariLifecycle hikariLifecycle = createLifecycle();
assertThat(hikariLifecycle.isRunning()).isTrue();
hikariLifecycle.stop();
assertThat(hikariLifecycle.getManagedInstance().isRunning()).isFalse();
assertThat(hikariLifecycle.getManagedInstance().isClosed()).isFalse();
assertThat(hikariLifecycle.isRunning()).isFalse();
assertThat(hikariLifecycle.getManagedInstance().getHikariPoolMXBean().getTotalConnections()).isZero();
hikariLifecycle.start();
assertThat(hikariLifecycle.getManagedInstance().isRunning()).isTrue();
assertThat(hikariLifecycle.getManagedInstance().isClosed()).isFalse();
assertThat(hikariLifecycle.isRunning()).isTrue();
}
@Test
void cannotStartClosedDataSource() {
HikariLifecycle hikariLifecycle = createLifecycle();
hikariLifecycle.getManagedInstance().close();
assertThatExceptionOfType(RuntimeException.class).isThrownBy(hikariLifecycle::start);
}
HikariLifecycle createLifecycle() {
HikariConfig config = new HikariConfig();
config.setAllowPoolSuspension(true);
config.setJdbcUrl("jdbc:hsqldb:mem:test-" + UUID.randomUUID());
config.setPoolName("lifecycle-tests");
HikariDataSource source = new HikariDataSource(config);
return new HikariLifecycle(source);
}
}

View File

@ -0,0 +1,149 @@
/*
* Copyright 2012-2023 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.jdbc;
import java.lang.reflect.Field;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Function;
import com.zaxxer.hikari.HikariConfigMXBean;
import com.zaxxer.hikari.HikariDataSource;
import com.zaxxer.hikari.HikariPoolMXBean;
import com.zaxxer.hikari.pool.HikariPool;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.context.Lifecycle;
import org.springframework.core.log.LogMessage;
import org.springframework.util.Assert;
import org.springframework.util.ReflectionUtils;
/**
* {@link Lifecycle} for a {@link HikariDataSource} allowing it to participate in
* checkpoint-restore. When {@link #stop() stopped}, and the data source
* {@link HikariDataSource#isAllowPoolSuspension() allows it}, its pool is suspended,
* blocking any attempts to borrow connections. Open and idle connections are then
* evicted. When subsequently {@link #start() started}, the pool is
* {@link HikariPoolMXBean#resumePool() resumed} if necessary.
*
* @author Christoph Strobl
* @author Andy Wilkinson
* @since 3.2.0
*/
public class HikariCheckpointRestoreLifecycle implements Lifecycle {
private static final Log logger = LogFactory.getLog(HikariCheckpointRestoreLifecycle.class);
private static final Field CLOSE_CONNECTION_EXECUTOR;
static {
Field closeConnectionExecutor = ReflectionUtils.findField(HikariPool.class, "closeConnectionExecutor");
Assert.notNull(closeConnectionExecutor, "Unable to locate closeConnectionExecutor for HikariPool");
Assert.isAssignable(ThreadPoolExecutor.class, closeConnectionExecutor.getType(),
"Expected ThreadPoolExecutor for closeConnectionExecutor but found %s"
.formatted(closeConnectionExecutor.getType()));
ReflectionUtils.makeAccessible(closeConnectionExecutor);
CLOSE_CONNECTION_EXECUTOR = closeConnectionExecutor;
}
private final Function<HikariPool, Boolean> hasOpenConnections;
private final HikariDataSource dataSource;
/**
* Creates a new {@code HikariCheckpointRestoreLifecycle} that will allow the given
* {@code dataSource} to participate in checkpoint-restore.
* @param dataSource the checkpoint-restore participant
*/
public HikariCheckpointRestoreLifecycle(HikariDataSource dataSource) {
this.dataSource = DataSourceUnwrapper.unwrap(dataSource, HikariConfigMXBean.class, HikariDataSource.class);
this.hasOpenConnections = (pool) -> {
ThreadPoolExecutor closeConnectionExecutor = (ThreadPoolExecutor) ReflectionUtils
.getField(CLOSE_CONNECTION_EXECUTOR, pool);
Assert.notNull(closeConnectionExecutor, "CloseConnectionExecutor was null");
return closeConnectionExecutor.getActiveCount() > 0;
};
}
@Override
public void start() {
if (this.dataSource.isRunning()) {
return;
}
Assert.state(!this.dataSource.isClosed(), "DataSource has been closed and cannot be restarted");
if (this.dataSource.isAllowPoolSuspension()) {
logger.info("Resuming Hikari pool");
this.dataSource.getHikariPoolMXBean().resumePool();
}
}
@Override
public void stop() {
if (!this.dataSource.isRunning()) {
return;
}
if (this.dataSource.isAllowPoolSuspension()) {
logger.info("Suspending Hikari pool");
this.dataSource.getHikariPoolMXBean().suspendPool();
}
closeConnections(Duration.ofMillis(this.dataSource.getConnectionTimeout() + 250));
}
private void closeConnections(Duration shutdownTimeout) {
logger.info("Evicting Hikari connections");
this.dataSource.getHikariPoolMXBean().softEvictConnections();
logger.debug("Waiting for Hikari connections to be closed");
CompletableFuture<Void> allConnectionsClosed = CompletableFuture.runAsync(this::waitForConnectionsToClose);
try {
allConnectionsClosed.get(shutdownTimeout.toMillis(), TimeUnit.MILLISECONDS);
logger.debug("Hikari connections closed");
}
catch (InterruptedException ex) {
logger.warn("Interrupted while waiting for connections to be closed", ex);
Thread.currentThread().interrupt();
}
catch (TimeoutException ex) {
logger.warn(LogMessage.format("Hikari connections could not be closed within %s", shutdownTimeout), ex);
}
catch (ExecutionException ex) {
throw new RuntimeException("Failed to close Hikari connections", ex);
}
}
private void waitForConnectionsToClose() {
while (this.hasOpenConnections.apply((HikariPool) this.dataSource.getHikariPoolMXBean())) {
try {
TimeUnit.MILLISECONDS.sleep(50);
}
catch (InterruptedException ex) {
logger.error("Interrupted while waiting for datasource connections to be closed", ex);
Thread.currentThread().interrupt();
}
}
}
@Override
public boolean isRunning() {
return this.dataSource.isRunning();
}
}

View File

@ -0,0 +1,85 @@
/*
* Copyright 2012-2023 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.jdbc;
import java.util.UUID;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatNoException;
/**
* Tests for {@link HikariCheckpointRestoreLifecycle}.
*
* @author Christoph Strobl
* @author Andy Wilkinson
*/
class HikariCheckpointRestoreLifecycleTests {
private final HikariCheckpointRestoreLifecycle lifecycle;
private final HikariDataSource dataSource;
HikariCheckpointRestoreLifecycleTests() {
HikariConfig config = new HikariConfig();
config.setAllowPoolSuspension(true);
config.setJdbcUrl("jdbc:hsqldb:mem:test-" + UUID.randomUUID());
config.setPoolName("lifecycle-tests");
this.dataSource = new HikariDataSource(config);
this.lifecycle = new HikariCheckpointRestoreLifecycle(this.dataSource);
}
@Test
void startedWhenStartedShouldSucceed() {
assertThat(this.lifecycle.isRunning()).isTrue();
this.lifecycle.start();
assertThat(this.lifecycle.isRunning()).isTrue();
}
@Test
void stopWhenStoppedShouldSucceed() {
assertThat(this.lifecycle.isRunning()).isTrue();
this.lifecycle.stop();
assertThat(this.dataSource.isRunning()).isFalse();
assertThatNoException().isThrownBy(this.lifecycle::stop);
}
@Test
void whenStoppedAndStartedDataSourceShouldPauseAndResume() {
assertThat(this.lifecycle.isRunning()).isTrue();
this.lifecycle.stop();
assertThat(this.dataSource.isRunning()).isFalse();
assertThat(this.dataSource.isClosed()).isFalse();
assertThat(this.lifecycle.isRunning()).isFalse();
assertThat(this.dataSource.getHikariPoolMXBean().getTotalConnections()).isZero();
this.lifecycle.start();
assertThat(this.dataSource.isRunning()).isTrue();
assertThat(this.dataSource.isClosed()).isFalse();
assertThat(this.lifecycle.isRunning()).isTrue();
}
@Test
void whenDataSourceIsClosedThenStartShouldThrow() {
this.dataSource.close();
assertThatExceptionOfType(RuntimeException.class).isThrownBy(this.lifecycle::start);
}
}