diff --git a/module/spring-boot-jdbc/src/main/java/org/springframework/boot/jdbc/HikariCheckpointRestoreLifecycle.java b/module/spring-boot-jdbc/src/main/java/org/springframework/boot/jdbc/HikariCheckpointRestoreLifecycle.java index e12ee0648d18..195223545c93 100644 --- a/module/spring-boot-jdbc/src/main/java/org/springframework/boot/jdbc/HikariCheckpointRestoreLifecycle.java +++ b/module/spring-boot-jdbc/src/main/java/org/springframework/boot/jdbc/HikariCheckpointRestoreLifecycle.java @@ -25,15 +25,11 @@ 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.jspecify.annotations.Nullable; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.Lifecycle; @@ -52,6 +48,7 @@ * @author Christoph Strobl * @author Andy Wilkinson * @author Moritz Halbritter + * @author Fabio Grassi * @since 3.2.0 */ public class HikariCheckpointRestoreLifecycle implements Lifecycle { @@ -72,21 +69,20 @@ public class HikariCheckpointRestoreLifecycle implements Lifecycle { private final Function hasOpenConnections; - private final @Nullable HikariDataSource dataSource; + private final HikariDataSource dataSource; private final ConfigurableApplicationContext applicationContext; /** * Creates a new {@code HikariCheckpointRestoreLifecycle} that will allow the given - * {@code dataSource} to participate in checkpoint-restore. The {@code dataSource} is - * {@link DataSourceUnwrapper#unwrap unwrapped} to a {@link HikariDataSource}. If such - * unwrapping is not possible, the lifecycle will have no effect. + * {@link HikariDataSource} to participate in checkpoint-restore. * @param dataSource the checkpoint-restore participant * @param applicationContext the application context * @since 3.4.0 */ - public HikariCheckpointRestoreLifecycle(DataSource dataSource, ConfigurableApplicationContext applicationContext) { - this.dataSource = DataSourceUnwrapper.unwrap(dataSource, HikariConfigMXBean.class, HikariDataSource.class); + public HikariCheckpointRestoreLifecycle(HikariDataSource dataSource, + ConfigurableApplicationContext applicationContext) { + this.dataSource = dataSource; this.applicationContext = applicationContext; this.hasOpenConnections = (pool) -> { ThreadPoolExecutor closeConnectionExecutor = (ThreadPoolExecutor) ReflectionUtils @@ -98,7 +94,7 @@ public HikariCheckpointRestoreLifecycle(DataSource dataSource, ConfigurableAppli @Override public void start() { - if (this.dataSource == null || this.dataSource.isRunning()) { + if (this.dataSource.isRunning()) { return; } Assert.state(!this.dataSource.isClosed(), "DataSource has been closed and cannot be restarted"); @@ -110,7 +106,7 @@ public void start() { @Override public void stop() { - if (this.dataSource == null || !this.dataSource.isRunning()) { + if (!this.dataSource.isRunning()) { return; } if (this.dataSource.isAllowPoolSuspension()) { @@ -164,7 +160,7 @@ private void waitForConnectionsToClose(HikariDataSource dataSource) { @Override public boolean isRunning() { - return this.dataSource != null && this.dataSource.isRunning(); + return this.dataSource.isRunning(); } } diff --git a/module/spring-boot-jdbc/src/main/java/org/springframework/boot/jdbc/OracleUcpCheckpointRestoreLifecycle.java b/module/spring-boot-jdbc/src/main/java/org/springframework/boot/jdbc/OracleUcpCheckpointRestoreLifecycle.java new file mode 100644 index 000000000000..bdc59f694bc9 --- /dev/null +++ b/module/spring-boot-jdbc/src/main/java/org/springframework/boot/jdbc/OracleUcpCheckpointRestoreLifecycle.java @@ -0,0 +1,128 @@ +/* + * Copyright 2012-present 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.sql.SQLException; +import java.util.Arrays; + +import oracle.ucp.UniversalConnectionPoolException; +import oracle.ucp.admin.UniversalConnectionPoolManager; +import oracle.ucp.admin.UniversalConnectionPoolManagerImpl; +import oracle.ucp.jdbc.JDBCConnectionPool; +import oracle.ucp.jdbc.PoolDataSourceImpl; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.context.Lifecycle; +import org.springframework.util.Assert; + +/** + * A {@link Lifecycle} over the connection pool of a single + * {@link oracle.ucp.jdbc.PoolDataSourceImpl}, which lets the pool be started and stopped + * along with the application context without being destroyed in between. + *

+ * {@link #start()} creates the pool when it does not exist yet, since a pool data source + * has no {@code connectionPoolName} until then, and UCP registers a freshly created pool + * in the stopped state. Both {@code start()} and {@link #stop()} guard on the current + * life cycle state, as UCP rejects a transition that has already happened. + * + * @author Fabio Grassi + * @since 4.1.0 + */ +public final class OracleUcpCheckpointRestoreLifecycle implements Lifecycle { + + private static final Logger logger = LoggerFactory.getLogger(OracleUcpCheckpointRestoreLifecycle.class); + + private final PoolDataSourceImpl poolDataSource; + + public OracleUcpCheckpointRestoreLifecycle(final PoolDataSourceImpl poolDataSource) { + Assert.notNull(poolDataSource, "Non null PoolDataSourceImpl instance expected"); + this.poolDataSource = poolDataSource; + } + + @Override + public void start() { + JDBCConnectionPool pool = getPool(this.poolDataSource.getConnectionPoolName()); + if (pool == null) { + pool = createPool(); + logger.info("Created new Oracle Universal Connection Pool named '{}'", pool.getName()); + } + if (!pool.isLifecycleRunning() && !pool.isLifecycleStarting()) { + doWithPool(pool::start); + logger.info("Oracle Universal Connection Pool '{}' started", pool.getName()); + } + } + + @Override + public void stop() { + final JDBCConnectionPool pool = getPool(this.poolDataSource.getConnectionPoolName()); + if (pool != null && !pool.isLifecycleStopped() && !pool.isLifecycleStopping()) { + doWithPool(pool::stop); + logger.info("Oracle Universal Connection Pool '{}' stopped", pool.getName()); + } + } + + @Override + public boolean isRunning() { + final JDBCConnectionPool pool = getPool(this.poolDataSource.getConnectionPoolName()); + final boolean isRunning = pool != null && pool.isLifecycleRunning(); + logger.info("Oracle Universal Connection Pool '{}' is {}running", this.poolDataSource.getConnectionPoolName(), + isRunning ? "" : "not "); + return isRunning; + } + + private JDBCConnectionPool createPool() { + try { + return (JDBCConnectionPool) this.poolDataSource.createUniversalConnectionPool(); + } + catch (SQLException sqle) { + throw new IllegalStateException("Failed to create new Oracle Universal Connection Pool", sqle); + } + } + + private static @Nullable JDBCConnectionPool getPool(final @Nullable String poolName) { + try { + final UniversalConnectionPoolManager mgr = UniversalConnectionPoolManagerImpl + .getUniversalConnectionPoolManager(); + if (Arrays.asList(mgr.getConnectionPoolNames()).contains(poolName)) { + return (JDBCConnectionPool) mgr.getConnectionPool(poolName); + } + } + catch (UniversalConnectionPoolException ucpe) { + throw new IllegalStateException("Failed to retrieve existing Oracle Universal Connection Pool", ucpe); + } + return null; + } + + private static void doWithPool(final PoolCommand command) { + try { + command.execute(); + } + catch (UniversalConnectionPoolException ucpe) { + throw new IllegalStateException("Oracle Universal Connection Pool command failed", ucpe); + } + } + + @FunctionalInterface + private interface PoolCommand { + + void execute() throws UniversalConnectionPoolException; + + } + +} diff --git a/module/spring-boot-jdbc/src/main/java/org/springframework/boot/jdbc/autoconfigure/DataSourceCheckpointRestoreConfiguration.java b/module/spring-boot-jdbc/src/main/java/org/springframework/boot/jdbc/autoconfigure/DataSourceCheckpointRestoreConfiguration.java index cdb5c25edab0..8b4252572f3d 100644 --- a/module/spring-boot-jdbc/src/main/java/org/springframework/boot/jdbc/autoconfigure/DataSourceCheckpointRestoreConfiguration.java +++ b/module/spring-boot-jdbc/src/main/java/org/springframework/boot/jdbc/autoconfigure/DataSourceCheckpointRestoreConfiguration.java @@ -16,27 +16,43 @@ package org.springframework.boot.jdbc.autoconfigure; +import java.util.Collection; +import java.util.LinkedList; +import java.util.function.Function; + import javax.sql.DataSource; +import com.zaxxer.hikari.HikariConfigMXBean; import com.zaxxer.hikari.HikariDataSource; +import oracle.jdbc.OracleConnection; +import oracle.ucp.jdbc.PoolDataSource; +import oracle.ucp.jdbc.PoolDataSourceImpl; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; 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.jdbc.DataSourceUnwrapper; import org.springframework.boot.jdbc.HikariCheckpointRestoreLifecycle; +import org.springframework.boot.jdbc.OracleUcpCheckpointRestoreLifecycle; +import org.springframework.boot.jdbc.autoconfigure.DataSourceCheckpointRestoreConfiguration.CheckpointRestorePoolsAvailableCondition; import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.Lifecycle; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; /** * Checkpoint-restore specific configuration. * * @author Olga Maciaszek-Sharma + * @author Fabio Grassi */ @Configuration(proxyBeanMethods = false) @ConditionalOnCheckpointRestore -@ConditionalOnBean(DataSource.class) +@Conditional(CheckpointRestorePoolsAvailableCondition.class) class DataSourceCheckpointRestoreConfiguration { @Configuration(proxyBeanMethods = false) @@ -45,9 +61,125 @@ static class Hikari { @Bean @ConditionalOnMissingBean - HikariCheckpointRestoreLifecycle hikariCheckpointRestoreLifecycle(DataSource dataSource, - ConfigurableApplicationContext applicationContext) { - return new HikariCheckpointRestoreLifecycle(dataSource, applicationContext); + HikariCheckpointRestoreLifecycleRegistry hikariCheckpointRestoreLifecycle( + final ObjectProvider dataSources, final ConfigurableApplicationContext applicationContext) { + return new HikariCheckpointRestoreLifecycleRegistry(dataSources, applicationContext); + } + + static final class HikariCheckpointRestoreLifecycleRegistry + extends DataSourceCheckpointRestoreLifecycleRegistry { + + HikariCheckpointRestoreLifecycleRegistry(final ObjectProvider dataSources, + final ConfigurableApplicationContext applicationContext) { + super(dataSources, HikariConfigMXBean.class, HikariDataSource.class, + hds -> new HikariCheckpointRestoreLifecycle(hds, applicationContext)); + } + + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass({ PoolDataSourceImpl.class, OracleConnection.class }) + static class OracleUcp { + + @Bean + @ConditionalOnMissingBean + OracleUcpCheckpointRestoreLifecycleRegistry oracleUcpCheckpointRestoreLifecycle( + final ObjectProvider dataSources) { + return new OracleUcpCheckpointRestoreLifecycleRegistry(dataSources); + } + + static final class OracleUcpCheckpointRestoreLifecycleRegistry + extends DataSourceCheckpointRestoreLifecycleRegistry { + + OracleUcpCheckpointRestoreLifecycleRegistry(final ObjectProvider dataSources) { + super(dataSources, PoolDataSource.class, PoolDataSourceImpl.class, + OracleUcpCheckpointRestoreLifecycle::new); + } + + } + + } + + static class CheckpointRestorePoolsAvailableCondition extends AnyNestedCondition { + + CheckpointRestorePoolsAvailableCondition() { + super(ConfigurationPhase.PARSE_CONFIGURATION); + } + + @ConditionalOnClass(HikariDataSource.class) + static class HickariAvailable { + + } + + @ConditionalOnClass({ PoolDataSourceImpl.class, OracleConnection.class }) + static class OracleUcpAvailable { + + } + + } + + /** + * A {@link Lifecycle} container that propagates {@code start()} and {@code stop()} + * signals to all its elements and {@code isRunning()} if and only if all its elements + * are running or there are no elements. + *

+ * This class implements also {@link SmartInitializingSingleton} to hook into the bean + * factory lifecyle after all singleton beans registration and iterate over all + * {@code DataSource}s, including the ones that are neither default nor autowire + * candidates, unwrap each of them to reach the underlying data source, supply it to + * the given factory to create a {@code Lifecycle} and add it its elements. + * + * @author Fabio Grassi + * @since 4.1.0 + */ + static sealed class DataSourceCheckpointRestoreLifecycleRegistry + implements SmartInitializingSingleton, Lifecycle { + + private final ObjectProvider dataSources; + + private final Class wrappingInterface; + + private final Class targetClass; + + private final Function lifecycleFactory; + + private final Collection lifecycles; + + DataSourceCheckpointRestoreLifecycleRegistry(final ObjectProvider dataSources, + final Class wrappingInterface, final Class targetClass, + final Function lifecycleFactory) { + this.dataSources = dataSources; + this.wrappingInterface = wrappingInterface; + this.targetClass = targetClass; + this.lifecycleFactory = lifecycleFactory; + this.lifecycles = new LinkedList<>(); + } + + @Override + public void afterSingletonsInstantiated() { + this.dataSources.stream(ObjectProvider.UNFILTERED, false).forEach(ds -> { + final T unwrapped = DataSourceUnwrapper.unwrap(ds, this.wrappingInterface, this.targetClass); + if (unwrapped != null) { + this.lifecycles.add(this.lifecycleFactory.apply(unwrapped)); + } + }); + } + + @Override + public void start() { + this.lifecycles.forEach(Lifecycle::start); + } + + @Override + public void stop() { + this.lifecycles.forEach(Lifecycle::stop); + } + + @Override + public boolean isRunning() { + return this.lifecycles.stream().allMatch(Lifecycle::isRunning); } } diff --git a/module/spring-boot-jdbc/src/test/java/org/springframework/boot/jdbc/HikariCheckpointRestoreLifecycleTests.java b/module/spring-boot-jdbc/src/test/java/org/springframework/boot/jdbc/HikariCheckpointRestoreLifecycleTests.java index 5b58b0d24fb8..603b294c3241 100644 --- a/module/spring-boot-jdbc/src/test/java/org/springframework/boot/jdbc/HikariCheckpointRestoreLifecycleTests.java +++ b/module/spring-boot-jdbc/src/test/java/org/springframework/boot/jdbc/HikariCheckpointRestoreLifecycleTests.java @@ -18,8 +18,6 @@ import java.util.UUID; -import javax.sql.DataSource; - import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; import org.junit.jupiter.api.Test; @@ -88,22 +86,4 @@ void whenDataSourceIsClosedThenStartShouldThrow() { assertThatExceptionOfType(RuntimeException.class).isThrownBy(this.lifecycle::start); } - @Test - void startHasNoEffectWhenDataSourceIsNotAHikariDataSource() { - HikariCheckpointRestoreLifecycle nonHikariLifecycle = new HikariCheckpointRestoreLifecycle( - mock(DataSource.class), mock(ConfigurableApplicationContext.class)); - assertThat(nonHikariLifecycle.isRunning()).isFalse(); - nonHikariLifecycle.start(); - assertThat(nonHikariLifecycle.isRunning()).isFalse(); - } - - @Test - void stopHasNoEffectWhenDataSourceIsNotAHikariDataSource() { - HikariCheckpointRestoreLifecycle nonHikariLifecycle = new HikariCheckpointRestoreLifecycle( - mock(DataSource.class), mock(ConfigurableApplicationContext.class)); - assertThat(nonHikariLifecycle.isRunning()).isFalse(); - nonHikariLifecycle.stop(); - assertThat(nonHikariLifecycle.isRunning()).isFalse(); - } - } diff --git a/module/spring-boot-jdbc/src/test/java/org/springframework/boot/jdbc/OracleUcpCheckpointRestoreLifecycleTests.java b/module/spring-boot-jdbc/src/test/java/org/springframework/boot/jdbc/OracleUcpCheckpointRestoreLifecycleTests.java new file mode 100644 index 000000000000..b2675ec7c4b0 --- /dev/null +++ b/module/spring-boot-jdbc/src/test/java/org/springframework/boot/jdbc/OracleUcpCheckpointRestoreLifecycleTests.java @@ -0,0 +1,446 @@ +/* + * Copyright 2012-present 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.sql.Connection; +import java.sql.SQLException; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +import oracle.ucp.UniversalConnectionPoolException; +import oracle.ucp.admin.UniversalConnectionPoolManager; +import oracle.ucp.admin.UniversalConnectionPoolManagerImpl; +import oracle.ucp.jdbc.PoolDataSourceImpl; +import oracle.ucp.util.Strings; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; + +import org.springframework.context.Lifecycle; +import org.springframework.util.Assert; + +import static oracle.ucp.UniversalConnectionPoolLifeCycleState.LIFE_CYCLE_RUNNING; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatList; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +/** + * Tests for {@link OracleUcpCheckpointRestoreLifecycle}. + *

+ * The class is a {@link Lifecycle} over the connection pool of a single + * {@link PoolDataSourceImpl}, so its behaviour is pinned through the two things an + * observer can see, namely the pool registered with the + * {@link UniversalConnectionPoolManager} and that pool's life cycle state. + *

+ * Two UCP facts drive most of these tests: + *

+ *

+ * The Universal Connection Pool Manager is a JVM-wide singleton, so this suite relies on + * unique pool names and on {@link #destroyAllConnectionPools()}. It must be run + * sequentially, which is the JUnit default. + * + * @author Fabio Grassi + * @since 4.1.0 + */ +class OracleUcpCheckpointRestoreLifecycleTests { + + @AfterEach + void destroyAllConnectionPools() { + poolNames().forEach(OracleUcpCheckpointRestoreLifecycleTests::poolDestroy); + } + + @ParameterizedTest + @NullAndEmptySource + void constructorCreatesInstanceWithoutTouchingThePool(final @Nullable String poolName) { + + final PoolDataSourceImpl pds = createPoolDataSource(poolName); + + final OracleUcpCheckpointRestoreLifecycle lifecycle = new OracleUcpCheckpointRestoreLifecycle(pds); + + assertAll("Check that construction alone creates no pool", + () -> assertThat(lifecycle).as("Check the instance").isNotNull(), + () -> assertThat(pds.getConnectionPoolName()).as("Check that the pool has no name yet").isNull(), + () -> assertThatList(poolNames()).as("Check that no pool has been registered").isEmpty()); + } + + @Test + void constructorThrowsExceptionWhenPoolDataSourceIsNull() { + assertThatThrownBy(() -> new OracleUcpCheckpointRestoreLifecycle(null)) + .isExactlyInstanceOf(IllegalArgumentException.class) + .hasMessage("Non null PoolDataSourceImpl instance expected"); + } + + @Test + void startCreatesAndStartsTheConnectionPool() { + + final String poolName = uniquePoolName("start"); + final OracleUcpCheckpointRestoreLifecycle lifecycle = new OracleUcpCheckpointRestoreLifecycle( + createPoolDataSource(poolName)); + assertThatList(poolNames()).as("Check that no pool exists before starting").doesNotContain(poolName); + + lifecycle.start(); + + assertAll("Check that the pool has been created and started", + () -> assertThatList(poolNames()).as("Check that the pool is registered").contains(poolName), + () -> assertThat(poolIsRunning(poolName)).as("Check that the pool is running").isTrue(), + () -> assertThat(lifecycle.isRunning()).as("Check isRunning").isTrue()); + } + + @Test + void startGeneratesAPoolNameWhenTheDataSourceHasNone() { + + // A pool data source has no name until its pool is created, at which point UCP + // generates one + final PoolDataSourceImpl pds = createPoolDataSource(null); + final OracleUcpCheckpointRestoreLifecycle lifecycle = new OracleUcpCheckpointRestoreLifecycle(pds); + + lifecycle.start(); + + final String poolName = pds.getConnectionPoolName(); + assertAll("Check that UCP has named and started the pool", + () -> assertThat(poolName).as("Check the generated pool name").isNotNull(), + () -> assertThatList(poolNames()).as("Check that the pool is registered").contains(poolName), + () -> assertThat(lifecycle.isRunning()).as("Check isRunning").isTrue()); + } + + @Test + void startAdoptsAnAlreadyCreatedConnectionPool() { + + // A pool created behind the lifecycle's back, for instance by the meter binder, + // must be started rather than created a second time + final String poolName = uniquePoolName("adopted"); + final PoolDataSourceImpl pds = createPoolDataSource(poolName); + createPool(pds); + assertThat(poolIsRunning(poolName)).as("Check that a freshly created pool is not running yet").isFalse(); + + new OracleUcpCheckpointRestoreLifecycle(pds).start(); + + assertAll("Check that the existing pool has been started in place", + () -> assertThatList(poolNames()).as("Check that there is still a single pool") + .containsExactly(poolName), + () -> assertThat(poolIsRunning(poolName)).as("Check that the pool is running").isTrue()); + } + + @Test + void startIsIdempotent() { + + // UCP fails a 'start' on a running pool with UCP-45060, so the guard in + // 'start' is what makes a repeated call safe + final String poolName = uniquePoolName("restart"); + final OracleUcpCheckpointRestoreLifecycle lifecycle = new OracleUcpCheckpointRestoreLifecycle( + createPoolDataSource(poolName)); + lifecycle.start(); + + assertThatNoException().as("Check that a second start does not throw").isThrownBy(lifecycle::start); + + assertAll("Check that the pool is untouched and still running", + () -> assertThatList(poolNames()).as("Check that no second pool has been created") + .containsExactly(poolName), + () -> assertThat(poolIsRunning(poolName)).as("Check that the pool is running").isTrue()); + } + + @Test + void stopStopsTheConnectionPoolWithoutDestroyingIt() { + + final String poolName = uniquePoolName("stop"); + final OracleUcpCheckpointRestoreLifecycle lifecycle = new OracleUcpCheckpointRestoreLifecycle( + createPoolDataSource(poolName)); + lifecycle.start(); + + lifecycle.stop(); + + assertAll("Check that the pool has been stopped but is still registered", + () -> assertThatList(poolNames()).as("Check that the pool still exists").contains(poolName), + () -> assertThat(poolIsRunning(poolName)).as("Check that the pool is not running").isFalse(), + () -> assertThat(lifecycle.isRunning()).as("Check isRunning").isFalse()); + } + + @Test + void stopIsIdempotent() { + + final String poolName = uniquePoolName("doubleStop"); + final OracleUcpCheckpointRestoreLifecycle lifecycle = new OracleUcpCheckpointRestoreLifecycle( + createPoolDataSource(poolName)); + lifecycle.start(); + lifecycle.stop(); + + assertThatNoException().as("Check that a second stop does not throw").isThrownBy(lifecycle::stop); + + assertThat(poolIsRunning(poolName)).as("Check that the pool is still not running").isFalse(); + } + + @Test + void stopDoesNothingWhenTheConnectionPoolWasNeverCreated() { + + final String poolName = uniquePoolName("neverCreated"); + final OracleUcpCheckpointRestoreLifecycle lifecycle = new OracleUcpCheckpointRestoreLifecycle( + createPoolDataSource(poolName)); + + assertThatNoException().as("Check that stopping an uncreated pool does not throw").isThrownBy(lifecycle::stop); + + assertThatList(poolNames()).as("Check that no pool has been created").isEmpty(); + } + + @Test + void stopDoesNothingWhenTheConnectionPoolNameIsNull() { + + // Nothing can be looked up without a name, so 'stop' must be a no-op rather + // than an error + final PoolDataSourceImpl pds = createPoolDataSource(null); + assertThat(pds.getConnectionPoolName()).as("Check that the pool has no name yet").isNull(); + + assertThatNoException().as("Check that stopping an unnamed pool does not throw") + .isThrownBy(new OracleUcpCheckpointRestoreLifecycle(pds)::stop); + + assertThatList(poolNames()).as("Check that no pool has been created").isEmpty(); + } + + @Test + void startAfterStopRestartsTheConnectionPool() { + + final String poolName = uniquePoolName("cycle"); + final OracleUcpCheckpointRestoreLifecycle lifecycle = new OracleUcpCheckpointRestoreLifecycle( + createPoolDataSource(poolName)); + lifecycle.start(); + lifecycle.stop(); + assertThat(lifecycle.isRunning()).as("Check that the pool is stopped").isFalse(); + + lifecycle.start(); + + assertAll("Check that the same pool has been restarted", + () -> assertThatList(poolNames()).as("Check that no second pool has been created") + .containsExactly(poolName), + () -> assertThat(lifecycle.isRunning()).as("Check isRunning").isTrue()); + } + + @Test + void startRecreatesAConnectionPoolDestroyedBehindItsBack() { + + // The destroyer unregisters the pool but leaves the data source's name set, so + // a later 'start' must create the pool again instead of failing the lookup + final String poolName = uniquePoolName("destroyed"); + final PoolDataSourceImpl pds = createPoolDataSource(poolName); + final OracleUcpCheckpointRestoreLifecycle lifecycle = new OracleUcpCheckpointRestoreLifecycle(pds); + lifecycle.start(); + poolDestroy(poolName); + assertThatList(poolNames()).as("Check that the pool is gone").doesNotContain(poolName); + + lifecycle.start(); + + assertAll("Check that the pool has been created again under the same name", + () -> assertThatList(poolNames()).as("Check that the pool is registered again").contains(poolName), + () -> assertThat(lifecycle.isRunning()).as("Check isRunning").isTrue()); + } + + @Test + void isRunningReturnsFalseWhenTheConnectionPoolWasNeverCreated() { + + final OracleUcpCheckpointRestoreLifecycle lifecycle = new OracleUcpCheckpointRestoreLifecycle( + createPoolDataSource(uniquePoolName("unstarted"))); + + assertThat(lifecycle.isRunning()).as("Check that an uncreated pool is not running").isFalse(); + } + + @Test + void isRunningReturnsFalseWhenTheConnectionPoolNameIsNull() { + + // No name means no lookup, which must be reported as 'not running' rather than + // blowing up + final PoolDataSourceImpl pds = createPoolDataSource(null); + assertThat(pds.getConnectionPoolName()).as("Check that the pool has no name yet").isNull(); + + assertThat(new OracleUcpCheckpointRestoreLifecycle(pds).isRunning()).as("Check isRunning").isFalse(); + } + + @Test + void isRunningReturnsFalseForACreatedButNotYetStartedConnectionPool() { + + // UCP registers a freshly created pool in the 'Stopped' state + final String poolName = uniquePoolName("created"); + final PoolDataSourceImpl pds = createPoolDataSource(poolName); + createPool(pds); + + assertAll("Check that a created pool is not running until started", + () -> assertThatList(poolNames()).as("Check that the pool is registered").contains(poolName), + () -> assertThat(new OracleUcpCheckpointRestoreLifecycle(pds).isRunning()).as("Check isRunning") + .isFalse()); + } + + @Test + void isRunningReturnsFalseWhenTheConnectionPoolIsDestroyedBehindItsBack() { + + final String poolName = uniquePoolName("vanished"); + final OracleUcpCheckpointRestoreLifecycle lifecycle = new OracleUcpCheckpointRestoreLifecycle( + createPoolDataSource(poolName)); + lifecycle.start(); + assertThat(lifecycle.isRunning()).as("Check that the pool is running first").isTrue(); + + poolDestroy(poolName); + + assertThat(lifecycle.isRunning()).as("Check isRunning after the pool has vanished").isFalse(); + } + + @Test + void startedConnectionPoolServesConnections() { + + // The point of starting the pool: proves the lifecycle leaves behind a usable + // pool and not merely a registered one + final String poolName = uniquePoolName("usable"); + final PoolDataSourceImpl pds = createPoolDataSource(poolName); + new OracleUcpCheckpointRestoreLifecycle(pds).start(); + + assertThatNoException().as("Check that a connection can be borrowed").isThrownBy(() -> { + try (Connection connection = pds.getConnection()) { + assertThat(connection.isValid(1)).as("Check that the connection is valid").isTrue(); + } + }); + } + + @Test + void lifecycleOnlyAffectsItsOwnConnectionPool() { + + final String ownPoolName = uniquePoolName("own"); + final String otherPoolName = uniquePoolName("other"); + final OracleUcpCheckpointRestoreLifecycle own = new OracleUcpCheckpointRestoreLifecycle( + createPoolDataSource(ownPoolName)); + final OracleUcpCheckpointRestoreLifecycle other = new OracleUcpCheckpointRestoreLifecycle( + createPoolDataSource(otherPoolName)); + own.start(); + other.start(); + + own.stop(); + + assertAll("Check that only the own pool has been stopped", + () -> assertThat(own.isRunning()).as("Check the own pool").isFalse(), + () -> assertThat(other.isRunning()).as("Check the other pool").isTrue(), + () -> assertThatList(poolNames()).as("Check that both pools are still registered") + .contains(ownPoolName, otherPoolName)); + } + + @Test + void twoLifecyclesOverTheSameDataSourceShareTheConnectionPool() { + + // Nothing prevents two lifecycles from wrapping the same data source, and both + // must then see the very same pool + final String poolName = uniquePoolName("shared"); + final PoolDataSourceImpl pds = createPoolDataSource(poolName); + final OracleUcpCheckpointRestoreLifecycle first = new OracleUcpCheckpointRestoreLifecycle(pds); + final OracleUcpCheckpointRestoreLifecycle second = new OracleUcpCheckpointRestoreLifecycle(pds); + + first.start(); + + assertAll("Check that the second lifecycle sees the pool started by the first one", + () -> assertThatList(poolNames()).as("Check that a single pool has been created") + .containsExactly(poolName), + () -> assertThat(second.isRunning()).as("Check isRunning on the second lifecycle").isTrue(), + () -> assertThatNoException().as("Check that the second start is a no-op").isThrownBy(second::start)); + + second.stop(); + + assertThat(first.isRunning()).as("Check that the first lifecycle sees the stop").isFalse(); + } + + private static String uniquePoolName(final String prefix) { + return prefix + "-" + UUID.randomUUID(); + } + + private static PoolDataSourceImpl createPoolDataSource(final @Nullable String poolName) { + final PoolDataSourceImpl poolDataSource = DataSourceBuilder.create() + .url("jdbc:hsqldb:mem:test-" + UUID.randomUUID()) + .type(PoolDataSourceImpl.class) + .build(); + if (!Strings.isNullOrEmpty(poolName)) { + try { + poolDataSource.setConnectionPoolName(poolName); + } + catch (SQLException e) { + throw new IllegalStateException("Oracle connection pool initialization failed", e); + } + } + return poolDataSource; + } + + private static void createPool(final PoolDataSourceImpl poolDataSource) { + try { + poolDataSource.createUniversalConnectionPool(); + } + catch (SQLException e) { + throw new IllegalStateException("Oracle connection pool creation failed", e); + } + } + + private static List poolNames() { + try { + return Arrays.asList(poolManager().getConnectionPoolNames()); + } + catch (UniversalConnectionPoolException ucpe) { + throw new IllegalStateException("Oracle connection pool listing failed", ucpe); + } + } + + private static boolean poolIsRunning(final String poolName) { + Assert.hasText(poolName, "'poolName' must not be null"); + if (!poolNames().contains(poolName)) { + return false; + } + try { + return poolManager().getConnectionPool(poolName).getLifeCycleState() == LIFE_CYCLE_RUNNING; + } + catch (UniversalConnectionPoolException ucpe) { + throw new IllegalStateException("Oracle connection pool lookup failed", ucpe); + } + } + + private static void poolDestroy(final String poolName) { + doWithManager(UniversalConnectionPoolManager::destroyConnectionPool, poolName); + } + + private static void doWithManager(final PoolCommand command, final String poolName) { + Assert.hasText(poolName, "'poolName' must not be null"); + try { + command.accept(poolManager(), poolName); + } + catch (UniversalConnectionPoolException ucpe) { + throw new IllegalStateException("Oracle connection pool action failed", ucpe); + } + } + + private static UniversalConnectionPoolManager poolManager() throws UniversalConnectionPoolException { + return UniversalConnectionPoolManagerImpl.getUniversalConnectionPoolManager(); + } + + @FunctionalInterface + private interface PoolCommand { + + void accept(final UniversalConnectionPoolManager mgr, final String poolName) + throws UniversalConnectionPoolException; + + } + +} diff --git a/module/spring-boot-jdbc/src/test/java/org/springframework/boot/jdbc/autoconfigure/DataSourceCheckpointRestoreConfigurationTests.java b/module/spring-boot-jdbc/src/test/java/org/springframework/boot/jdbc/autoconfigure/DataSourceCheckpointRestoreConfigurationTests.java new file mode 100644 index 000000000000..d27111538dc9 --- /dev/null +++ b/module/spring-boot-jdbc/src/test/java/org/springframework/boot/jdbc/autoconfigure/DataSourceCheckpointRestoreConfigurationTests.java @@ -0,0 +1,158 @@ +/* + * Copyright 2012-present 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.autoconfigure; + +import java.util.Random; + +import net.bytebuddy.ByteBuddy; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.jdbc.autoconfigure.DataSourceCheckpointRestoreConfiguration.DataSourceCheckpointRestoreLifecycleRegistry; +import org.springframework.boot.jdbc.autoconfigure.DataSourceCheckpointRestoreConfiguration.Hikari.HikariCheckpointRestoreLifecycleRegistry; +import org.springframework.boot.jdbc.autoconfigure.DataSourceCheckpointRestoreConfiguration.OracleUcp.OracleUcpCheckpointRestoreLifecycleRegistry; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.util.ClassUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * @author Fabio Grassi + */ +public class DataSourceCheckpointRestoreConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(DataSourceCheckpointRestoreConfiguration.class)) + .withPropertyValues("spring.datasource.url:jdbc:hsqldb:mem:testdb-" + new Random().nextInt()); + + @Test + void whenCracIsNotAvailableNoLifeCycleIsInstrumented() { + this.contextRunner.run((context) -> { + final ClassLoader cl = context.getClassLoader(); + assertAll("When CRaC is not available, no life cycle is instrumented", + () -> assertFalse(ClassUtils.isPresent("org.crac.Resource", cl)), + () -> assertTrue(ClassUtils.isPresent("com.zaxxer.hikari.HikariDataSource", cl)), + () -> assertTrue(ClassUtils.isPresent("oracle.ucp.jdbc.PoolDataSourceImpl", cl)), + () -> assertTrue(ClassUtils.isPresent("oracle.jdbc.OracleConnection", cl)), + // + () -> assertThat(context).doesNotHaveBean(DataSourceCheckpointRestoreLifecycleRegistry.class)); + }); + } + + @Test + void whenPoolsAreNotAvailableNoLifeCycleIsInstrumented() { + this.contextRunner + .withClassLoader(cracEnabledClassLoader( + new FilteredClassLoader("com.zaxxer.hikari", "oracle.ucp.jdbc", "oracle.jdbc"))) + .run((context) -> { + final ClassLoader cl = context.getClassLoader(); + assertAll("When supported connection pools are not available, no life cycle is instrumented", + () -> assertTrue(ClassUtils.isPresent("org.crac.Resource", cl)), + () -> assertFalse(ClassUtils.isPresent("com.zaxxer.hikari.HikariDataSource", cl)), + () -> assertFalse(ClassUtils.isPresent("oracle.ucp.jdbc.PoolDataSourceImpl", cl)), + () -> assertFalse(ClassUtils.isPresent("oracle.jdbc.OracleConnection", cl)), + // + () -> assertThat(context).doesNotHaveBean(DataSourceCheckpointRestoreLifecycleRegistry.class)); + }); + } + + @Test + void whenCracAndHikariAreAvailableThenLifeCycleIsInstrumented() { + this.contextRunner + .withClassLoader(cracEnabledClassLoader(new FilteredClassLoader("oracle.ucp.jdbc", "oracle.jdbc"))) + .run((context) -> { + final ClassLoader cl = context.getClassLoader(); + assertAll("When CRaC and Hikari are available, then life cycle is instrumented", + () -> assertTrue(ClassUtils.isPresent("org.crac.Resource", cl)), + () -> assertTrue(ClassUtils.isPresent("com.zaxxer.hikari.HikariDataSource", cl)), + () -> assertFalse(ClassUtils.isPresent("oracle.ucp.jdbc.PoolDataSourceImpl", cl)), + () -> assertFalse(ClassUtils.isPresent("oracle.jdbc.OracleConnection", cl)), + // + () -> assertThat(context).hasSingleBean(HikariCheckpointRestoreLifecycleRegistry.class), + () -> assertThat(context).doesNotHaveBean(OracleUcpCheckpointRestoreLifecycleRegistry.class)); + }); + } + + @Test + void whenOracleUcpIsAvailableThenLifeCycleIsInstrumented() { + this.contextRunner.withClassLoader(cracEnabledClassLoader(new FilteredClassLoader("com.zaxxer.hikari"))) + .run((context) -> { + final ClassLoader cl = context.getClassLoader(); + assertAll("When CRaC and Oracle UCP are available, then life cycle is instrumented", + () -> assertTrue(ClassUtils.isPresent("org.crac.Resource", cl)), + () -> assertFalse(ClassUtils.isPresent("com.zaxxer.hikari.HikariDataSource", cl)), + () -> assertTrue(ClassUtils.isPresent("oracle.ucp.jdbc.PoolDataSourceImpl", cl)), + () -> assertTrue(ClassUtils.isPresent("oracle.jdbc.OracleConnection", cl)), + // + () -> assertThat(context).doesNotHaveBean(HikariCheckpointRestoreLifecycleRegistry.class), + () -> assertThat(context).hasSingleBean(OracleUcpCheckpointRestoreLifecycleRegistry.class)); + }); + } + + @Test + void shouldBackoffWhenCustomHikariLifeCyclePresent() { + this.contextRunner + .withClassLoader(cracEnabledClassLoader(new FilteredClassLoader("oracle.ucp.jdbc", "oracle.jdbc"))) + .withBean("customHikariCheckpointRestoreLifecycle", HikariCheckpointRestoreLifecycleRegistry.class) + .run((context) -> { + final ClassLoader cl = context.getClassLoader(); + assertAll("When a custom Hikari life cycle is present, then autoconfiguration should back off", + () -> assertTrue(ClassUtils.isPresent("org.crac.Resource", cl)), + () -> assertTrue(ClassUtils.isPresent("com.zaxxer.hikari.HikariDataSource", cl)), + () -> assertFalse(ClassUtils.isPresent("oracle.ucp.jdbc.PoolDataSourceImpl", cl)), + () -> assertFalse(ClassUtils.isPresent("oracle.jdbc.OracleConnection", cl)), + // + () -> assertThat(context).hasSingleBean(HikariCheckpointRestoreLifecycleRegistry.class), + () -> assertThat(context).hasBean("customHikariCheckpointRestoreLifecycle"), + () -> assertThat(context).doesNotHaveBean("hikariCheckpointRestoreLifecycle"), + () -> assertThat(context).doesNotHaveBean(OracleUcpCheckpointRestoreLifecycleRegistry.class)); + }); + } + + @Test + void shouldBackoffWhenCustomOracleUcpLifeCyclePresent() { + this.contextRunner.withClassLoader(cracEnabledClassLoader(new FilteredClassLoader("com.zaxxer.hikari"))) + .withBean("customOracleUcpCheckpointRestoreLifecycle", OracleUcpCheckpointRestoreLifecycleRegistry.class) + .run((context) -> { + final ClassLoader cl = context.getClassLoader(); + assertAll("When a custom Oracle UCP life cycle is present, then autoconfiguration should back off", + () -> assertTrue(ClassUtils.isPresent("org.crac.Resource", cl)), + () -> assertFalse(ClassUtils.isPresent("com.zaxxer.hikari.HikariDataSource", cl)), + () -> assertTrue(ClassUtils.isPresent("oracle.ucp.jdbc.PoolDataSourceImpl", cl)), + () -> assertTrue(ClassUtils.isPresent("oracle.jdbc.OracleConnection", cl)), + // + () -> assertThat(context).doesNotHaveBean(HikariCheckpointRestoreLifecycleRegistry.class), + () -> assertThat(context).hasSingleBean(OracleUcpCheckpointRestoreLifecycleRegistry.class), + () -> assertThat(context).hasBean("customOracleUcpCheckpointRestoreLifecycle"), + () -> assertThat(context).doesNotHaveBean("oracleUcpCheckpointRestoreLifecycle")); + }); + } + + private static ClassLoader cracEnabledClassLoader(final ClassLoader parent) { + return new ByteBuddy().subclass(Object.class) + .name("org.crac.Resource") + .make() + .load(parent) + .getLoaded() + .getClassLoader(); + } + +} diff --git a/module/spring-boot-jdbc/src/test/java/org/springframework/boot/jdbc/autoconfigure/OracleUcpDataSourceCheckpointRestoreConfigurationTests.java b/module/spring-boot-jdbc/src/test/java/org/springframework/boot/jdbc/autoconfigure/OracleUcpDataSourceCheckpointRestoreConfigurationTests.java new file mode 100644 index 000000000000..47e14499d1a2 --- /dev/null +++ b/module/spring-boot-jdbc/src/test/java/org/springframework/boot/jdbc/autoconfigure/OracleUcpDataSourceCheckpointRestoreConfigurationTests.java @@ -0,0 +1,447 @@ +/* + * Copyright 2012-present 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.autoconfigure; + +import java.sql.SQLException; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +import javax.sql.DataSource; + +import oracle.jdbc.OracleConnection; +import oracle.ucp.UniversalConnectionPoolException; +import oracle.ucp.admin.UniversalConnectionPoolManager; +import oracle.ucp.admin.UniversalConnectionPoolManagerImpl; +import oracle.ucp.jdbc.PoolDataSourceImpl; +import org.hsqldb.jdbc.JDBCDataSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.boot.jdbc.OracleUcpCheckpointRestoreLifecycle; +import org.springframework.boot.jdbc.autoconfigure.DataSourceCheckpointRestoreConfiguration.OracleUcp.OracleUcpCheckpointRestoreLifecycleRegistry; +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.context.ConfigurableApplicationContext; +import org.springframework.context.Lifecycle; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.jdbc.datasource.DelegatingDataSource; +import org.springframework.util.Assert; + +import static oracle.ucp.UniversalConnectionPoolLifeCycleState.LIFE_CYCLE_RUNNING; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatList; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.junit.jupiter.api.Assertions.assertAll; + +/** + * Tests for {@link OracleUcpCheckpointRestoreLifecycleRegistry} a {@link Lifecycle} + * aggregating one {@link OracleUcpCheckpointRestoreLifecycle} per UCP data source found + * in the context, which starts and stops those pools along with the context without + * destroying them in between. + *

+ * Two Spring facts shape the life cycle tests. + * {@code OracleUcpCheckpointRestoreLifecycleRegistry} is a plain {@code Lifecycle} rather + * than a {@code SmartLifecycle}, so the context does not auto start it on + * refresh: a pool is only materialized once + * {@link ConfigurableApplicationContext#start()} is called explicitly. On {@code close()} + * the processor stops the life cycle beans first and destroys the singletons afterwards, + * which is why a closed context leaves no pool behind at all. + *

+ * The Universal Connection Pool Manager is a JVM-wide singleton, so this suite relies on + * {@link #destroyAllConnectionPools()} and must be run sequentially, which is the JUnit + * default. + * + * @author Fabio Grassi + * @since 4.1.0 + */ +class OracleUcpDataSourceCheckpointRestoreConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.datasource.generate-unique-name=true") + .withConfiguration(AutoConfigurations.of(DataSourceCheckpointRestoreConfiguration.OracleUcp.class)); + + @AfterEach + void destroyAllConnectionPools() { + poolNames().forEach(OracleUcpDataSourceCheckpointRestoreConfigurationTests::destroyPool); + } + + @Test + void poolDataSourceImplLifecycleIsAutoConfigured() { + this.contextRunner.withConfiguration(AutoConfigurations.of(DataSourceAutoConfiguration.class)) + .run(context -> assertThat(context).hasSingleBean(OracleUcpCheckpointRestoreLifecycleRegistry.class)); + } + + @Test + void poolDataSourceImplLifecycleIsNotAutoConfiguredWithoutUcpOnTheClasspath() { + this.contextRunner.withUserConfiguration(OnePoolDataSourceConfiguration.class) + .withClassLoader(new FilteredClassLoader(PoolDataSourceImpl.class)) + .run(context -> assertThat(context).doesNotHaveBean(OracleUcpCheckpointRestoreLifecycleRegistry.class)); + } + + @Test + void poolDataSourceImplLifecycleIsNotAutoConfiguredWithoutOjdbcOnTheClasspath() { + this.contextRunner.withUserConfiguration(OnePoolDataSourceConfiguration.class) + .withClassLoader(new FilteredClassLoader(OracleConnection.class)) + .run(context -> assertThat(context).doesNotHaveBean(OracleUcpCheckpointRestoreLifecycleRegistry.class)); + } + + @Test + void poolDataSourceImplLifecycleIsNotAutoConfiguredWithoutDataSource() { + this.contextRunner.run(context -> { + assertThat(context).doesNotHaveBean(DataSource.class); + assertThat(context).doesNotHaveBean(OracleUcpCheckpointRestoreLifecycleRegistry.class); + }); + } + + @Test + void refreshingTheContextCreatesNoConnectionPool() { + // A plain Lifecycle is not auto started, so merely refreshing the context must + // leave the pools unmaterialized + this.contextRunner.withUserConfiguration(TwoPoolDataSourcesConfiguration.class).run(context -> { + assertThat(context).hasSingleBean(OracleUcpCheckpointRestoreLifecycleRegistry.class); + assertThatList(poolNames()).as("Check that no pool has been created") + .doesNotContain("firstPoolDataSource", "secondPoolDataSource"); + }); + } + + @Test + void startingTheContextCreatesAndStartsTheConnectionPool() { + this.contextRunner.withUserConfiguration(OnePoolDataSourceConfiguration.class).run(context -> { + + startContext(context); + + assertAll("Check that the pool has been created and started", + () -> assertThatList(poolNames()).as("Check that the pool is registered") + .contains("poolDataSource"), + () -> assertThat(poolIsRunning("poolDataSource")).as("Check that the pool is running").isTrue(), + () -> assertThat(lifecycleOf(context).isRunning()).as("Check isRunning").isTrue()); + }); + } + + @Test + void startingTheContextStartsEveryConnectionPool() { + this.contextRunner.withUserConfiguration(TwoPoolDataSourcesConfiguration.class).run(context -> { + + startContext(context); + + assertAll("Check that both pools are running", + () -> assertThat(poolIsRunning("firstPoolDataSource")).as("Check first pool").isTrue(), + () -> assertThat(poolIsRunning("secondPoolDataSource")).as("Check second pool").isTrue(), + () -> assertThat(lifecycleOf(context).isRunning()).as("Check isRunning").isTrue()); + }); + } + + @Test + void startingTheContextStartsTheConnectionPoolBehindADelegatingDataSource() { + // Proves the unwrapping is wired end to end for the life cycle too + this.contextRunner.withUserConfiguration(DelegatingDataSourceConfiguration.class).run(context -> { + + startContext(context); + + assertThat(poolIsRunning("delegatedPool")).as("Check that the pool is running").isTrue(); + }); + } + + @Test + void startedConnectionPoolServesConnections() { + // The point of starting the pools: the context is left with usable pools, not + // merely registered ones + this.contextRunner.withUserConfiguration(OnePoolDataSourceConfiguration.class).run(context -> { + startContext(context); + + assertThatNoException().as("Check that a connection can be borrowed") + .isThrownBy(() -> context.getBean("poolDataSource", DataSource.class).getConnection().close()); + }); + } + + @Test + void stoppingTheContextStopsTheConnectionPoolWithoutDestroyingIt() { + this.contextRunner.withUserConfiguration(OnePoolDataSourceConfiguration.class).run(context -> { + startContext(context); + + stopContext(context); + + assertAll("Check that the pool has been stopped but is still registered", + () -> assertThatList(poolNames()).as("Check that the pool still exists").contains("poolDataSource"), + () -> assertThat(poolIsRunning("poolDataSource")).as("Check that the pool is not running") + .isFalse(), + () -> assertThat(lifecycleOf(context).isRunning()).as("Check isRunning").isFalse()); + }); + } + + @Test + void restartingTheContextRestartsTheSameConnectionPool() { + this.contextRunner.withUserConfiguration(OnePoolDataSourceConfiguration.class).run(context -> { + startContext(context); + stopContext(context); + + startContext(context); + + assertAll("Check that the very same pool has been restarted", + () -> assertThatList(poolNames()).as("Check that no second pool exists") + .containsOnlyOnce("poolDataSource"), + () -> assertThat(poolIsRunning("poolDataSource")).as("Check that the pool is running again") + .isTrue()); + }); + } + + @Test + void startingTheContextTwiceIsHarmless() { + // UCP fails a 'start' on an already running pool with UCP-45060, so the guards + // in OracleUcpLifecycle are what make this safe + this.contextRunner.withUserConfiguration(OnePoolDataSourceConfiguration.class).run(context -> { + startContext(context); + + assertThatNoException().as("Check that a second start does not throw") + .isThrownBy(() -> startContext(context)); + + assertThat(poolIsRunning("poolDataSource")).as("Check that the pool is still running").isTrue(); + }); + } + + @Test + void stoppingTheContextTwiceIsHarmless() { + this.contextRunner.withUserConfiguration(OnePoolDataSourceConfiguration.class).run(context -> { + startContext(context); + stopContext(context); + + assertThatNoException().as("Check that a second stop does not throw") + .isThrownBy(() -> stopContext(context)); + + assertThat(poolIsRunning("poolDataSource")).as("Check that the pool is still not running").isFalse(); + }); + } + + @Test + void nonUcpDataSourceIsIgnored() { + this.contextRunner.withUserConfiguration(MixedDataSourcesConfiguration.class).run(context -> { + + startContext(context); + + assertAll("Check that only the UCP data source has a pool", + () -> assertThat(poolIsRunning("poolDataSource")).as("Check the UCP pool").isTrue(), + () -> assertThatList(poolNames()) + .as("Check that the plain JDBC data source has contributed no pool") + .containsExactly("poolDataSource")); + }); + } + + @Test + void lifecycleIsContributedButEmptyWithoutAnyUcpDataSource() { + // The bean is conditional on a DataSource, not on a UCP one, so a context with + // only plain data sources still gets an aggregate with nothing to manage. An + // empty aggregate reports itself as running, which merely means the context is + // free to call stop() on it + this.contextRunner.withUserConfiguration(OnlyJdbcDataSourceConfiguration.class).run(context -> { + assertThat(context).hasSingleBean(OracleUcpCheckpointRestoreLifecycleRegistry.class); + + assertThatNoException().as("Check that starting and stopping are no-ops").isThrownBy(() -> { + startContext(context); + stopContext(context); + }); + + assertThatList(poolNames()).as("Check that no pool has been created").isEmpty(); + }); + } + + @Test + void isRunningIsFalseUnlessEveryConnectionPoolIsRunning() { + // The aggregate is conjunctive: one stopped pool is enough to report the whole + // set as not running + this.contextRunner.withUserConfiguration(TwoPoolDataSourcesConfiguration.class).run(context -> { + startContext(context); + assertThat(lifecycleOf(context).isRunning()).as("Check isRunning with both pools running").isTrue(); + + stopPool("firstPoolDataSource"); + + assertAll("Check that a single stopped pool flips the aggregate", + () -> assertThat(lifecycleOf(context).isRunning()).as("Check isRunning").isFalse(), + () -> assertThat(poolIsRunning("secondPoolDataSource")).as("Check that the other pool is untouched") + .isTrue()); + }); + } + + @Test + void startRecreatesAConnectionPoolDestroyedBehindItsBack() { + // The destroyer unregisters a pool but leaves the data source's name set, so a + // later context start must create it again instead of failing the lookup + this.contextRunner.withUserConfiguration(OnePoolDataSourceConfiguration.class).run(context -> { + startContext(context); + destroyPool("poolDataSource"); + assertThatList(poolNames()).as("Check that the pool is gone").doesNotContain("poolDataSource"); + + startContext(context); + + assertThat(poolIsRunning("poolDataSource")) + .as("Check that the pool has been created again under the same name") + .isTrue(); + }); + } + + private static void startContext(final AssertableApplicationContext context) { + context.getSourceApplicationContext().start(); + } + + private static void stopContext(final AssertableApplicationContext context) { + context.getSourceApplicationContext().stop(); + } + + private static Lifecycle lifecycleOf(final AssertableApplicationContext context) { + return context.getBean(OracleUcpCheckpointRestoreLifecycleRegistry.class); + } + + private static PoolDataSourceImpl createPoolDataSource(final String poolName) { + final PoolDataSourceImpl poolDataSource = DataSourceBuilder.create() + .url("jdbc:hsqldb:mem:test-" + UUID.randomUUID()) + .type(PoolDataSourceImpl.class) + .build(); + try { + poolDataSource.setConnectionPoolName(poolName); + } + catch (SQLException e) { + throw new InvalidDataAccessApiUsageException( + "Cannot set Oracle UCP connection pool name '" + poolName + "'", e); + } + return poolDataSource; + } + + private static JDBCDataSource createJdbcDataSource() { + return DataSourceBuilder.create() + .url("jdbc:hsqldb:mem:test-" + UUID.randomUUID()) + .type(JDBCDataSource.class) + .build(); + } + + @Configuration(proxyBeanMethods = false) + static class OnePoolDataSourceConfiguration { + + @Bean + DataSource poolDataSource() { + return createPoolDataSource("poolDataSource"); + } + + } + + @Configuration(proxyBeanMethods = false) + static class TwoPoolDataSourcesConfiguration { + + @Bean + DataSource firstPoolDataSource() { + return createPoolDataSource("firstPoolDataSource"); + } + + @Bean + DataSource secondPoolDataSource() { + return createPoolDataSource("secondPoolDataSource"); + } + + } + + @Configuration(proxyBeanMethods = false) + static class DelegatingDataSourceConfiguration { + + @Bean + DataSource delegatingDataSource() { + return new DelegatingDataSource(createPoolDataSource("delegatedPool")); + } + + } + + @Configuration(proxyBeanMethods = false) + static class MixedDataSourcesConfiguration { + + @Bean + DataSource poolDataSource() { + return createPoolDataSource("poolDataSource"); + } + + @Bean + DataSource jdbcDataSource() { + return createJdbcDataSource(); + } + + } + + @Configuration(proxyBeanMethods = false) + static class OnlyJdbcDataSourceConfiguration { + + @Bean + DataSource jdbcDataSource() { + return createJdbcDataSource(); + } + + } + + private static List poolNames() { + try { + return Arrays.asList(poolManager().getConnectionPoolNames()); + } + catch (UniversalConnectionPoolException ucpe) { + throw new IllegalStateException("Oracle connection pool listing failed", ucpe); + } + } + + private static boolean poolIsRunning(final String poolName) { + Assert.hasText(poolName, "'poolName' must not be null"); + if (!poolNames().contains(poolName)) { + return false; + } + try { + return poolManager().getConnectionPool(poolName).getLifeCycleState() == LIFE_CYCLE_RUNNING; + } + catch (UniversalConnectionPoolException ucpe) { + throw new IllegalStateException("Oracle connection pool lookup failed", ucpe); + } + } + + private static void destroyPool(final String poolName) { + doWithManager(UniversalConnectionPoolManager::destroyConnectionPool, poolName); + } + + private static void stopPool(final String poolName) { + doWithManager(UniversalConnectionPoolManager::stopConnectionPool, poolName); + } + + private static void doWithManager(final PoolCommand command, final String poolName) { + Assert.hasText(poolName, "'poolName' must not be null"); + try { + command.accept(poolManager(), poolName); + } + catch (UniversalConnectionPoolException ucpe) { + throw new IllegalStateException("Oracle connection pool action failed", ucpe); + } + } + + private static UniversalConnectionPoolManager poolManager() throws UniversalConnectionPoolException { + return UniversalConnectionPoolManagerImpl.getUniversalConnectionPoolManager(); + } + + @FunctionalInterface + private interface PoolCommand { + + void accept(final UniversalConnectionPoolManager mgr, final String poolName) + throws UniversalConnectionPoolException; + + } + +}